Shoemaker rework

pull/356/head
Cory LaViska 2021-02-26 09:09:13 -05:00
rodzic 4eeeffc493
commit fe45f2159f
202 zmienionych plików z 6778 dodań i 19633 usunięć

Wyświetl plik

@ -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"
}
}

38
.gitignore vendored
Wyświetl plik

@ -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
Thumbs.db
UserInterfaceState.xcuserstate
.env
.cache
docs/dist
dist
examples
node_modules

Wyświetl plik

@ -1,11 +1,9 @@
.github
*.md
.cache
.stencil
.github
dist
docs/assets
docs/**/*.md
loader
docs/*.md
src/components/icon/icons
node_modules
src/components/**/readme.md
src/components.d.ts
package-lock.json
tsconfig.json

Wyświetl plik

@ -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?
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.
The build is done through a combination of Stencil's CLI and a handful of custom scripts.
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/).
### Forking the Repo
@ -50,14 +48,14 @@ npm install
Once you've cloned the repo, run the following command.
```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

Wyświetl plik

@ -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);

Wyświetl plik

Wyświetl plik

@ -23,7 +23,6 @@
- [Form](/components/form.md)
- [Icon](/components/icon.md)
- [Icon Button](/components/icon-button.md)
- [Icon Library](/components/icon-library.md)
- [Image Comparer](/components/image-comparer.md)
- [Input](/components/input.md)
- [Menu](/components/menu.md)
@ -55,7 +54,6 @@
- [Include](/components/include.md)
- [Relative Time](/components/relative-time.md)
- [Resize Observer](/components/resize-observer.md)
- [Theme](/components/theme.md)
- Design Tokens
- [Typography](/tokens/typography.md)

Wyświetl plik

@ -1,8 +1,18 @@
<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>. 🤯
<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>. 🤯
</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>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>
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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -18,7 +18,7 @@
}
/* Block the preview while dragging to prevent iframes from intercepting drag events */
.code-block__preview--dragging::after {
.code-block__preview--dragging:after {
content: '';
position: absolute;
top: 0;

Wyświetl plik

@ -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('touchstart', dragStart);

Wyświetl plik

@ -1,6 +1,10 @@
(() => {
let metadataStore;
function getAttrName(propName) {
return propName.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
}
function createPropsTable(props) {
const table = document.createElement('table');
table.innerHTML = `
@ -14,26 +18,30 @@
</thead>
<tbody>
${props
.map(
prop => `
<tr>
<td>
<code>${escapeHtml(prop.name)}</code>
${prop.name !== prop.attr && prop.attr !== undefined ? (`
<br>
<small>
<sl-tooltip content="Use this name in your HTML">
<code class="attribute-tooltip">${escapeHtml(prop.attr)}</code>
</sl-tooltip>
</small>`
) : ''}
</td>
<td>${escapeHtml(prop.docs)}</td>
<td><code style="white-space: normal;">${escapeHtml(prop.type)}</code></td>
<td><code style="white-space: normal;">${escapeHtml(prop.default)}</code></td>
</tr>
`
)
.map(prop => {
const attr = getAttrName(prop.name);
return `
<tr>
<td>
<code>${escapeHtml(prop.name)}</code>
${
prop.name !== attr
? `
<br>
<small>
<sl-tooltip content="This is the attribute name">
<code class="attribute-tooltip">${escapeHtml(attr)}</code>
</sl-tooltip>
</small>`
: ''
}
</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('')}
</tbody>
`;
@ -48,7 +56,6 @@
<tr>
<th>Event</th>
<th>Description</th>
<th>Type</th>
</tr>
</thead>
<tbody>
@ -56,9 +63,8 @@
.map(
event => `
<tr>
<td><code>${escapeHtml(event.event)}</code></td>
<td>${escapeHtml(event.docs)}</td>
<td><code style="white-space: normal;">CustomEvent&lt;${escapeHtml(event.detail)}&gt;</code></td>
<td><code>${escapeHtml(event.name)}</code></td>
<td>${escapeHtml(event.description)}</td>
</tr>
`
)
@ -76,19 +82,29 @@
<tr>
<th>Method</th>
<th>Description</th>
<th>Signature</th>
<th>Arguments</th>
</tr>
</thead>
<tbody>
${methods
.map(
method => `
<tr>
<td><code>${escapeHtml(method.name)}</code></td>
<td>${escapeHtml(method.docs)}</td>
<td><code style="white-space: normal;">${escapeHtml(method.signature)}</code></td>
</tr>
`
<tr>
<td><code>${escapeHtml(method.name)}</code></td>
<td>${escapeHtml(method.description)}</td>
<td>
${
method.params.length
? `
<code style="white-space: normal;">${escapeHtml(
method.params.map(param => `${param.name}: ${param.type}`).join(', ')
)}</code>
`
: ''
}
</td>
</tr>
`
)
.join('')}
</tbody>
@ -112,7 +128,7 @@
slot => `
<tr>
<td><code>${slot.name ? escapeHtml(slot.name) : '(default)'}</code></td>
<td>${escapeHtml(slot.docs)}</td>
<td>${escapeHtml(slot.description)}</td>
</tr>
`
)
@ -138,7 +154,7 @@
style => `
<tr>
<td><code>${escapeHtml(style.name)}</code></td>
<td>${escapeHtml(style.docs)}</td>
<td>${escapeHtml(style.description)}</td>
</tr>
`
)
@ -164,7 +180,7 @@
part => `
<tr>
<td><code>${escapeHtml(part.name)}</code></td>
<td>${escapeHtml(part.docs)}</td>
<td>${escapeHtml(part.description)}</td>
</tr>
`
)
@ -175,22 +191,29 @@
return table.outerHTML;
}
function createDependenciesList(dependencies, dependencyGraph) {
const all = [...dependencies];
function createDependenciesList(targetComponent, allComponents) {
const ul = document.createElement('ul');
const dependencies = [];
// Gather subdependencies from the dependency graph
Object.keys(dependencyGraph).map(key => {
dependencyGraph[key].map(subdep => {
if (!all.includes(subdep)) {
all.push(subdep);
// Recursively fetch subdependencies
function getDependencies(tag) {
const component = allComponents.find(c => c.tag === tag);
if (!component && !Array.isArray(component.dependencies)) {
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');
li.innerHTML = `<code>${dependency}</code>`;
li.innerHTML = `<code>&lt;${tag}&gt;</code>`;
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) {
throw new Error('Docsify must be loaded before installing this plugin.');
}
window.$docsify.plugins.push((hook, vm) => {
hook.mounted(function () {
getMetadata()
.then(metadata => {
const target = document.querySelector('.app-name');
getMetadata().then(metadata => {
const target = document.querySelector('.app-name');
// Add version
const version = document.createElement('div');
version.classList.add('sidebar-version');
version.textContent = metadata.version;
target.appendChild(version);
// Add version
const version = document.createElement('div');
version.classList.add('sidebar-version');
version.textContent = metadata.version;
target.appendChild(version);
// Add repo buttons
const buttons = document.createElement('div');
buttons.classList.add('sidebar-buttons');
buttons.innerHTML = `
// Add repo buttons
const buttons = document.createElement('div');
buttons.classList.add('sidebar-buttons');
buttons.innerHTML = `
<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
</a>
<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 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>
`;
target.appendChild(buttons);
});
target.appendChild(buttons);
});
});
hook.beforeEach(async function (content, next) {
@ -276,39 +288,33 @@
// Handle [component-header] tags
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 = '';
if (!data) {
if (!component) {
console.error('Component not found in metadata: ' + tag);
next(content);
}
const tags = getDocsTagsObject(data.docsTags);
if (!tags) {
console.error(`No metadata tags found for ${tag}`);
return;
}
let badgeType = 'info';
if (tags.status === 'stable') badgeType = 'primary';
if (tags.status === 'experimental') badgeType = 'warning';
if (tags.status === 'planned') badgeType = 'info';
if (tags.status === 'deprecated') badgeType = 'danger';
if (component.status === 'stable') badgeType = 'primary';
if (component.status === 'experimental') badgeType = 'warning';
if (component.status === 'planned') badgeType = 'info';
if (component.status === 'deprecated') badgeType = 'danger';
result += `
<div class="component-header">
<div class="component-header__tag">
<code>&lt;${tag}&gt;</code>
<code>${component.className} | &lt;${component.tag}&gt;</code>
</div>
<div class="component-header__info">
<sl-badge type="info" pill>
Since ${tags.since || '?'}
Since ${component.since || '?'}
</sl-badge>
<sl-badge type="${badgeType}" pill style="text-transform: capitalize;">
${tags.status}
${component.status}
</sl-badge>
</div>
</div>
@ -319,64 +325,64 @@
// Handle [component-metadata] tags
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 = '';
if (!data) {
if (!component) {
console.error('Component not found in metadata: ' + tag);
next(content);
}
if (data.props.length) {
if (component.props.length) {
result += `
## Properties
${createPropsTable(data.props)}
${createPropsTable(component.props)}
`;
}
if (data.events.length) {
if (component.events.length) {
result += `
## Events
${createEventsTable(data.events)}
${createEventsTable(component.events)}
`;
}
if (data.methods.length) {
if (component.methods.length) {
result += `
## Methods
${createMethodsTable(data.methods)}
${createMethodsTable(component.methods)}
`;
}
if (data.slots.length) {
if (component.slots.length) {
result += `
## Slots
${createSlotsTable(data.slots)}
${createSlotsTable(component.slots)}
`;
}
if (data.styles.length) {
if (component.cssCustomProperties.length) {
result += `
## CSS Custom Properties
${createCustomPropertiesTable(data.styles)}
${createCustomPropertiesTable(component.cssCustomProperties)}
`;
}
if (data.parts.length) {
if (component.parts.length) {
result += `
## CSS Parts
${createPartsTable(data.parts)}
${createPartsTable(component.parts)}
`;
}
if (data.dependencies.length) {
if (component.dependencies.length) {
result += `
## Dependencies
This component has the following dependencies. If you're not using the lazy loader, be sure to import and
register these components in addition to <code>${tag}</code>.
This component has the following dependencies so, if you're [cherry picking](/getting-started/installation#cherry-picking),
be sure to import these components in addition to <code>&lt;${tag}&gt;</code>.
${createDependenciesList(data.dependencies, data.dependencyGraph)}
${createDependenciesList(component.tag, metadata.components)}
`;
}

Wyświetl plik

@ -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);
});
});
});
})();

Wyświetl plik

@ -8,7 +8,7 @@
// Move search below the app name
const appName = document.querySelector('.sidebar .app-name');
const search = document.querySelector('.sidebar .search');
appName.insertAdjacentElement("afterend", search);
});
appName.insertAdjacentElement('afterend', search);
});
});
})();

Wyświetl plik

@ -1,13 +1,13 @@
.theme-toggle {
position: fixed;
top: .5rem;
right: .5rem;
top: 0.5rem;
right: 0.5rem;
z-index: 100;
}
@media screen and (max-width: 768px) {
.theme-toggle {
top: .25rem;
right: .25rem;
top: 0.25rem;
right: 0.25rem;
}
}

Wyświetl plik

@ -21,7 +21,7 @@
height: 2rem;
}
.transition-demo::after {
.transition-demo:after {
content: '';
position: absolute;
background-color: var(--sl-color-primary-500);
@ -33,7 +33,7 @@
transition-property: width;
}
.transition-demo:hover::after {
.transition-demo:hover:after {
width: 100%;
}

Wyświetl plik

@ -92,11 +92,11 @@
/* Tips & warnings */
.sl-theme-dark .markdown-section p.tip,
.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.warn::before {
.sl-theme-dark .markdown-section p.tip:before,
.sl-theme-dark .markdown-section p.warn:before {
color: var(--sl-color-gray-900);
}
@ -112,7 +112,7 @@
/* Code blocks */
.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);
}
@ -175,7 +175,7 @@
}
/* Repo buttons */
.sl-theme-dark .repo-button {
.sl-theme-dark .repo-button {
background-color: var(--sl-color-gray-900);
border-color: var(--sl-color-gray-800);
color: var(--sl-color-gray-200);
@ -185,12 +185,11 @@
color: var(--sl-color-white);
}
.sl-theme-dark .repo-button:hover {
.sl-theme-dark .repo-button:hover {
background-color: var(--sl-color-gray-900);
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);
}

Wyświetl plik

@ -2,7 +2,9 @@ html {
box-sizing: border-box;
}
*, *:before, *:after {
*,
*:before,
*:after {
box-sizing: inherit;
}
@ -40,7 +42,7 @@ strong {
color: var(--sl-color-gray-400);
text-align: right;
padding: 0 var(--sl-spacing-small);
margin: -1.25rem 0 .6rem 0;
margin: -1.25rem 0 0.6rem 0;
}
.sidebar-buttons {
@ -76,7 +78,7 @@ strong {
.sidebar .input-wrap {
position: relative;
width: 100%;
padding: 0 .25rem;
padding: 0 0.25rem;
}
.sidebar .clear-button {
@ -88,7 +90,7 @@ strong {
}
.sidebar .clear-button svg {
transform: scale(.75) !important;
transform: scale(0.75) !important;
}
.sidebar .clear-button:focus {
@ -101,13 +103,13 @@ strong {
.search .matching-post {
border-bottom: solid 1px var(--sl-color-gray-500) !important;
padding: .25rem 1.5rem;
padding: 0.25rem 1.5rem;
}
.search .matching-post a {
display: block;
border-radius: inherit
padding: .5rem;
border-radius: inherit;
padding: 0.5rem;
}
.search .matching-post h2 {
@ -120,13 +122,13 @@ strong {
/* Sidebar toggle */
.sidebar-toggle {
top: .25rem;
left: .25rem;
top: 0.25rem;
left: 0.25rem;
width: 2rem;
height: 2rem;
border-radius: var(--sl-border-radius-medium);
background-color: var(--sl-color-white);
padding: .5rem;
padding: 0.5rem;
}
.sidebar-toggle:focus {
@ -142,7 +144,7 @@ strong {
body.close .sidebar-toggle {
width: 2rem;
background: none;
padding: .5rem;
padding: 0.5rem;
}
}
@ -160,8 +162,8 @@ strong {
color: inherit;
text-decoration: none;
line-height: 1.5em;
padding-top: .25em;
padding-bottom: .25em;
padding-top: 0.25em;
padding-bottom: 0.25em;
}
.sidebar-nav li.collapse > a,
@ -172,20 +174,19 @@ strong {
.sidebar li > p {
font-weight: var(--sl-font-weight-bold);
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 {
padding-left: .5rem;
margin: 0 .75rem 1.5rem 0;
padding-left: 0.5rem;
margin: 0 0.75rem 1.5rem 0;
}
.sidebar ul ul ul {
padding: 0;
margin: 0 0 0 .5rem;
margin: 0 0 0 0.5rem;
}
.sidebar ul ul ul li {
list-style: disc;
margin-left: 1.5rem;
@ -375,7 +376,7 @@ strong {
}
.namespace {
opacity: .7;
opacity: 0.7;
}
.markdown-section pre .token.property,
@ -559,14 +560,15 @@ strong {
/* Repo buttons */
html .repo-button {
display: inline-block;
vertical-align: middle;
display: inline-flex;
align-items: center;
background-color: var(--sl-color-white);
border: solid 1px var(--sl-color-gray-200);
border-radius: var(--sl-border-radius-medium);
box-shadow: var(--sl-shadow-x-small);
font-size: var(--sl-font-size-small);
font-weight: var(--sl-font-weight-semibold);
line-height: 2;
text-decoration: none;
color: var(--sl-color-gray-700);
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) {
margin-right: .125rem;
margin-right: 0.125rem;
}
html .repo-button sl-icon {
position: relative;
top: -1px;
vertical-align: middle;
margin-right: 0.125rem;
margin-right: 0.35rem;
}
html .repo-button--small {
@ -614,7 +615,7 @@ html .repo-button--twitter sl-icon {
color: #1ea0f2;
}
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,
body[data-page^='tokens/'] .table-wrapper td:first-child code {
white-space: nowrap;
}

Wyświetl plik

@ -46,32 +46,32 @@ This example demonstrates all of the baked-in animations and easings. Animations
</div>
</div>
<script>
<script type="module">
import { getAnimationNames, getEasingNames } from '/dist/shoelace.js';
const container = document.querySelector('.animation-sandbox');
const animation = container.querySelector('sl-animation');
const animationName = container.querySelector('.controls sl-select:nth-child(1)');
const easingName = container.querySelector('.controls sl-select:nth-child(2)');
const playbackRate = container.querySelector('sl-range');
const animations = getAnimationNames();
const easings = getEasingNames();
animation.getAnimationNames().then(names => {
names.map(name => {
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
textContent: name,
value: name
});
animationName.appendChild(menuItem);
animations.map(name => {
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
textContent: name,
value: name
});
animationName.appendChild(menuItem);
});
animation.getEasingNames().then(names => {
names.map(name => {
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
textContent: name,
value: name
});
easingName.appendChild(menuItem);
easings.map(name => {
const menuItem = Object.assign(document.createElement('sl-menu-item'), {
textContent: name,
value: name
});
});
easingName.appendChild(menuItem);
});
animationName.addEventListener('sl-change', () => animation.name = animationName.value);
easingName.addEventListener('sl-change', () => animation.easing = easingName.value);

Wyświetl plik

@ -82,8 +82,8 @@ When including badges in menu items, use the `suffix` slot to make sure they're
```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-label>Messages</sl-menu-label>
<sl-menu-item>Comments <sl-badge slot="suffix" 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>Comments <sl-badge slot="suffix" type="info" pill>4</sl-badge></sl-menu-item>
<sl-menu-item>Replies <sl-badge slot="suffix" type="info" pill>12</sl-badge></sl-menu-item>
</sl-menu>
```

Wyświetl plik

@ -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]

Wyświetl plik

@ -4,7 +4,7 @@
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.
@ -56,15 +56,410 @@ Icons are sized relative to the current font size. To change their size, set the
### 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
<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 -->
<script>
fetch('/dist/shoelace/icons/icons.json')
fetch('/dist/assets/icons/icons.json')
.then(res => res.json())
.then(icons => {
const container = document.querySelector('.icon-search');

Wyświetl plik

@ -53,13 +53,7 @@ Use the `disable` attribute to disable the rating.
### Custom Icons
```html preview
<sl-rating class="rating-hearts" style="--symbol-color-active: #ff4136;"></sl-rating>
<script>
const rating = document.querySelector('.rating-hearts');
rating.getSymbol = () => '<sl-icon name="heart-fill"></sl-icon>';
</script>
<sl-rating symbol="heart-fill" style="--symbol-color-active: #ff4136;"></sl-rating>
```
### Value-based Icons
@ -70,9 +64,9 @@ Use the `disable` attribute to disable the rating.
<script>
const rating = document.querySelector('.rating-emojis');
rating.getSymbol = (value) => {
rating.symbol = (value) => {
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>
```

Wyświetl plik

@ -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]

Wyświetl plik

@ -86,7 +86,7 @@ Use the `placement` attribute to set the preferred placement of the tooltip.
width: 250px;
}
.tooltip-placement-example-row::after {
.tooltip-placement-example-row:after {
content: '';
display: table;
clear: both;

Wyświetl plik

@ -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._ 🐛
## 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 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)
- 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
@ -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 `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 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-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)
@ -29,7 +50,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
## 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)
- 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`)
@ -50,17 +71,17 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
## 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
- 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
- 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
- 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 `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 visual separators between solid buttons in `sl-button-group`
- 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
- 🚨 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`
- 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)
@ -145,7 +166,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis
## 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 `spellcheck` prop to `sl-input`
- Added `sl-icon-library` to allow custom icon library registration

Wyświetl plik

@ -4,13 +4,11 @@ You can use Shoelace via CDN or by installing it locally.
## 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. ✨
Just add the following tags to your page.
The easiest way to install Shoelace is with the CDN. Just add the following tags to your page.
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace/shoelace.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace/shoelace.esm.js"></script>
<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/all.shoelace.js"></script>
```
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
```
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.
```html
<link rel="stylesheet" href="/assets/shoelace/shoelace.css">
<script type="module" src="/assets/shoelace/shoelace.esm.js"></script>
<link rel="stylesheet" href="/scripts/shoelace/dist/themes/base.css">
<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
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
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
import '@shoelace-style/shoelace/dist/shoelace.css';
import { setBasePath, SlButton, SlIcon, SlInput, SlRating } from '@shoelace-style/shoelace';
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
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')
}
]
})
]
};
// Set the pase path to the folder you copied Shoelace's assets to
setBasePath('/dist/shoelace');
SlButton.register();
SlIcon.register();
SlInput.register();
SlRating.register();
// <sl-button>, <sl-icon>, <sl-input>, and <sl-rating> are ready to use!
```
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 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.
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.

Wyświetl plik

@ -29,8 +29,8 @@
Add the following code to your page.
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace/shoelace.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/dist/shoelace/shoelace.esm.js"></script>
<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/all.shoelace.js"></script>
```
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 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 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>
## Attribution
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/)
- CDN services are provided by [jsDelivr](https://www.jsdelivr.com/)
- The default theme is based on color palettes from [Tailwind](https://tailwindcss.com/)

Wyświetl plik

@ -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.
```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
<sl-button>Light Mode</sl-button>
<sl-theme name="dark">
<sl-button>Dark Mode</sl-button>
</sl-theme>
<body class="sl-theme-dark">
...
</body>
```
### 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
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
<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">
</head>
<body>
<sl-theme name="purple-power">
...
</sl-theme>
<body class="sl-theme-purple-power">
...
</body>
```

Wyświetl plik

@ -41,9 +41,9 @@
<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/touch-icon.png" />
<!-- Import Shoelace -->
<link rel="stylesheet" href="/dist/shoelace/shoelace.css" />
<link rel="stylesheet" href="/themes/dark.css" />
<script type="module" src="/dist/shoelace/shoelace.esm.js"></script>
<link rel="stylesheet" href="/dist/themes/base.css" />
<link rel="stylesheet" href="/dist/themes/dark.css" />
<script type="module" src="/dist/all.shoelace.js"></script>
</head>
<body>
<div id="app"></div>
@ -74,7 +74,7 @@
crossChapter: true,
crossChapterText: false
},
routerMode: window.ShoelaceDevServer ? 'hash' : 'history',
routerMode: location.port ? 'hash' : 'history',
search: {
maxAge: 86400000, // Expiration time, the default one day
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-jsx.min.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/sidebar/sidebar.js"></script>
<script src="/assets/plugins/theme/theme.js"></script>

Wyświetl plik

@ -22,7 +22,7 @@ yarn add @shoelace-style/shoelace @shoelace-style/react-wrapper copy-webpack-plu
The next step is to import Shoelace's default theme (stylesheet) in your `_app.js` file:
```css
@import '~@shoelace-style/shoelace/dist/shoelace/shoelace';
@import '~@shoelace-style/shoelace/dist/themes/base';
```
### Defining Custom Elements
@ -40,10 +40,13 @@ function CustomEls({ URL }) {
if (customEls.current) {
return;
}
setAssetPath(`${URL}/static/static`);
// If you're wanting to selectively import components, replace this line with your own definitions
defineCustomElements();
// customElements.define("sl-button", SlButton);
setBasePath(`${URL}/static/static`);
// Define the components you intend to use
customElements.define("sl-alert", SlAlert);
customElements.define("sl-button", SlButton);
// ...
customEls.current = true;
}, [URL, customEls]);
@ -74,7 +77,7 @@ function MyApp({ Component, pageProps, URL }) {
### 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"
@ -96,7 +99,7 @@ MyApp.getInitialProps = async (context) => {
### 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
const path = require("path");
@ -110,9 +113,9 @@ module.exports = {
{
from: path.resolve(
__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

Wyświetl plik

@ -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`.
```css
@import '~@shoelace-style/shoelace/dist/shoelace/shoelace';
@import '~@shoelace-style/shoelace/dist/themes/base';
```
### Importing Required Scripts
@ -32,25 +32,20 @@ After importing the theme, you'll need to import the JavaScript files for Shoela
```js
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.*$/, '')
// 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)
setAssetPath(rootUrl + '/packs/js/')
// This enables all web components for the current page
defineCustomElements()
setBasePath(rootUrl + '/packs/js/')
```
?> 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
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
const { environment } = require('@rails/webpacker')
@ -59,7 +54,7 @@ const { environment } = require('@rails/webpacker')
const path = require('path')
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(
'CopyPlugin',
new CopyPlugin({
@ -67,9 +62,9 @@ environment.plugins.append(
{
from: path.resolve(
__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')
}
]
})

Wyświetl plik

@ -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);
}
})();

10008
package-lock.json wygenerowano

Plik diff jest za duży Load Diff

Wyświetl plik

@ -2,25 +2,24 @@
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.0.0-beta.27",
"homepage": "https://shoelace.style/",
"homepage": "https://github.com/shoelace-style/shoelace",
"author": "Cory LaViska",
"license": "MIT",
"main": "dist/index.cjs.js",
"module": "dist/custom-elements-bundle/index.js",
"es2015": "dist/esm/index.js",
"es2017": "dist/esm/index.js",
"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",
"main": "dist/shoelace.js",
"module": "dist/shoelace.js",
"type": "module",
"types": "dist/shoelace.d.ts",
"files": [
"dist/",
"loader/",
"themes/"
"dist"
],
"keywords": [
"web components",
"custom elements",
"components"
],
"repository": {
"type": "git",
"url": "git://github.com/shoelace-style/shoelace.git"
"url": "git+https://github.com/shoelace-style/shoelace.git"
},
"bugs": {
"url": "https://github.com/shoelace-style/shoelace/issues"
@ -30,62 +29,51 @@
"url": "https://github.com/sponsors/claviska"
},
"scripts": {
"build": "stencil build --docs",
"dev": "npm run make-icons && stencil build --dev --docs --watch --serve --port 4001 --no-open",
"lint": "eslint src/**/*{.ts,.tsx}",
"make-dist": "node make-dist.js",
"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"
"start": "node scripts/build.cjs --serve",
"build": "node scripts/build.cjs",
"prepublish": "npm run build",
"prettier": "prettier --write --loglevel warn ."
},
"dependencies": {
"@popperjs/core": "^2.5.3",
"@shoelace-style/animations": "^1.0.0",
"@stencil/core": "^2.4.0",
"@types/resize-observer-browser": "^0.1.4",
"color": "^3.1.2"
"@popperjs/core": "^2.7.0",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/shoemaker": "^1.0.0-beta.6",
"color": "^3.1.3"
},
"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": {
"hooks": {
"pre-commit": "npm run prettier && npm run lint"
"pre-commit": "npm run prettier"
}
}
}

140
scripts/build.cjs 100644
Wyświetl plik

@ -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());
}
})();

Wyświetl plik

@ -1,25 +1,31 @@
//
// This script downloads and generates icons and icon metadata.
//
const Promise = require('bluebird');
const promisify = require('util').promisify;
const chalk = require('chalk');
const copy = require('recursive-copy');
const del = require('del');
const download = require('download');
const mkdirp = require('mkdirp');
const fm = require('front-matter');
const fs = require('fs').promises;
const glob = promisify(require('glob'));
const path = require('path');
const baseDir = path.dirname(__dirname);
const iconDir = './dist/assets/icons';
let numIcons = 0;
(async () => {
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 url = `https://github.com/twbs/icons/archive/v${version}.zip`;
try {
await fs.stat(`${srcPath}/LICENSE.md`);
console.log(chalk.cyan('Generating icons from cache ♻️'));
console.log(chalk.cyan('Generating icons from cache'));
} catch {
// Download the source from GitHub (since not everything is published to NPM)
console.log(chalk.cyan(`Downloading and extracting Bootstrap Icons ${version} 📦`));
@ -27,16 +33,17 @@ let numIcons = 0;
}
// Copy icons
console.log(chalk.cyan(`Copying icons and license 🚛`));
await del(['./src/components/icon/icons']);
console.log(chalk.cyan(`Copying icons and license`));
await del([iconDir]);
await mkdirp(iconDir);
await Promise.all([
copy(`${srcPath}/icons`, './src/components/icon/icons'),
copy(`${srcPath}/LICENSE.md`, './src/components/icon/icons/LICENSE.md'),
copy(`${srcPath}/icons`, iconDir),
copy(`${srcPath}/LICENSE.md`, path.join(iconDir, 'LICENSE.md')),
copy(`${srcPath}/bootstrap-icons.svg`, './docs/assets/icons/sprite.svg', { overwrite: true })
]);
// 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 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) {
console.error(err);
}

Wyświetl plik

@ -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`));
})();

Wyświetl plik

@ -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();
}
});

3164
src/components.d.ts vendored

Plik diff jest za duży Load Diff

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -1,5 +1,5 @@
@import 'component';
@import 'mixins/hidden';
@use '../../styles/component';
@use '../../styles/mixins/hide';
/**
* @prop --box-shadow: The alert's box shadow.
@ -31,7 +31,7 @@
margin: inherit;
&:not(.alert--visible) {
@include hidden;
@include hide.hidden;
}
}

Wyświetl plik

@ -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' });
@ -6,6 +7,8 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl
* @since 2.0
* @status stable
*
* @dependency sl-icon-button
*
* @slot - The alert's content.
* @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 message - The alert message.
* @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({
tag: 'sl-alert',
styleUrl: 'alert.scss',
shadow: true
})
export class Alert {
alert: HTMLElement;
autoHideTimeout: any;
@Element() host: HTMLSlAlertElement;
@State() isVisible = false;
private autoHideTimeout: any;
private isVisible = false;
/** 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. */
@Prop({ reflect: true }) closable = false;
/** Makes the alert closable. */
closable = false;
/** 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
* alert before it closes (e.g. moves the mouse over it), the timer will restart.
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`.
*/
@Prop() duration = Infinity;
duration = Infinity;
@Watch('open')
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() {
onConnect() {
// Show on init if open
if (this.open) {
this.show();
@ -79,14 +54,13 @@ export class Alert {
}
/** Shows the alert. */
@Method()
async show() {
show() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (this.isVisible) {
return;
}
const slShow = this.slShow.emit();
const slShow = this.emit('sl-show');
if (slShow.defaultPrevented) {
this.open = false;
return;
@ -101,14 +75,13 @@ export class Alert {
}
/** Hides the alert */
@Method()
async hide() {
hide() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (!this.isVisible) {
return;
}
const slHide = this.slHide.emit();
const slHide = this.emit('sl-hide');
if (slHide.defaultPrevented) {
this.open = true;
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
* dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse it by
* calling this method again. The returned promise will resolve after the alert is hidden.
* Displays the alert as a toast notification. This will move the alert out of its position in the DOM
* and, when dismissed, it will be removed from the DOM completely. By storing a reference to the alert, you can reuse
* it by calling this method again. The returned promise will resolve after the alert is hidden.
*/
@Method()
async toast() {
return new Promise<void>(resolve => {
if (!toastStack.parentElement) {
document.body.append(toastStack);
}
toastStack.append(this.host);
toastStack.appendChild(this);
requestAnimationFrame(() => this.show());
this.host.addEventListener(
this.addEventListener(
'sl-after-hide',
() => {
this.host.remove();
toastStack.removeChild(this);
resolve();
// 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();
}
},
@ -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() {
this.hide();
}
@ -164,23 +143,23 @@ export class Alert {
// Ensure we only emit one event when the target element is no longer visible
if (event.propertyName === 'opacity' && target.classList.contains('alert')) {
this.isVisible = this.open;
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
this.open ? this.emit('sl-after-show') : this.emit('sl-after-hide');
}
}
restartAutoHide() {
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = setTimeout(() => this.hide(), this.duration);
}
watchOpen() {
this.open ? this.show() : this.hide();
}
watchDuration() {
this.restartAutoHide();
}
render() {
return (
return html`
<div
ref={el => (this.alert = el)}
part="base"
class={{
class=${classMap({
alert: true,
'alert--open': this.open,
'alert--visible': this.isVisible,
@ -190,13 +169,13 @@ export class Alert {
'alert--info': this.type === 'info',
'alert--warning': this.type === 'warning',
'alert--danger': this.type === 'danger'
}}
})}
role="alert"
aria-live="assertive"
aria-atomic="true"
aria-hidden={this.open ? 'false' : 'true'}
onMouseMove={this.handleMouseMove}
onTransitionEnd={this.handleTransitionEnd}
aria-hidden=${this.open ? 'false' : 'true'}
onmousemove=${this.handleMouseMove.bind(this)}
ontransitionend=${this.handleTransitionEnd.bind(this)}
>
<span part="icon" class="alert__icon">
<slot name="icon" />
@ -206,12 +185,14 @@ export class Alert {
<slot />
</span>
{this.closable && (
<span class="alert__close">
<sl-icon-button exportparts="base:close-button" name="x" onClick={this.handleCloseClick} />
</span>
)}
${this.closable
? html`
<span class="alert__close">
<sl-icon-button exportparts="base:close-button" name="x" onclick=${this.handleCloseClick.bind(this)} />
</span>
`
: ''}
</div>
);
`;
}
}

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: contents;

Wyświetl plik

@ -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 { easings } from './easings';
/**
* @since 2.0
* @status stable
*
* @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({
tag: 'sl-animation',
styleUrl: 'animation.scss',
shadow: true
})
export class Animate {
animation: Animation;
hasStarted = false;
export default class SlAnimation extends Shoemaker {
static tag = 'sl-animation';
static props = [
'name',
'delay',
'direction',
'duration',
'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. */
@Prop() name = 'none';
name = 'none';
/** 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. */
@Prop() direction: PlaybackDirection = 'normal';
direction: PlaybackDirection = 'normal';
/** 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
* 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. */
@Prop() endDelay = 0;
endDelay = 0;
/** 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. */
@Prop() iterations: number = Infinity;
iterations: number = Infinity;
/** 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. */
@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
* 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.
*/
@Prop() playbackRate = 1;
playbackRate = 1;
/** 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
@Watch('delay')
@Watch('direction')
@Watch('easing')
@Watch('endDelay')
@Watch('fill')
@Watch('iterations')
@Watch('iterationStart')
@Watch('keyframes')
@Watch('name')
handleRestartAnimation() {
onReady() {
this.createAnimation();
}
@Watch('pause')
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() {
onDisconnect() {
this.destroyAnimation();
}
handleAnimationFinish() {
this.slFinish.emit();
this.emit('sl-finish');
}
handleAnimationCancel() {
this.slCancel.emit();
this.emit('sl-cancel');
}
handlePlaybackRateChange() {
this.animation.playbackRate = this.playbackRate;
}
handleSlotChange() {
@ -128,10 +104,9 @@ export class Animate {
}
createAnimation() {
const easing = easings[this.easing] || this.easing;
const keyframes: Keyframe[] = this.keyframes ? this.keyframes : animations[this.name];
const slot = this.host.shadowRoot.querySelector('slot');
const element = slot.assignedElements({ flatten: true })[0] as HTMLElement;
const easing = animations.easings[this.easing] || this.easing;
const keyframes: Keyframe[] = this.keyframes ? this.keyframes : (animations as any)[this.name];
const element = this.defaultSlot.assignedElements({ flatten: true })[0] as HTMLElement;
if (!element) {
return;
@ -156,7 +131,7 @@ export class Animate {
this.animation.pause();
} else {
this.hasStarted = true;
this.slStart.emit();
this.emit('sl-start');
}
}
@ -165,52 +140,87 @@ export class Animate {
this.animation.cancel();
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
this.animation.removeEventListener('finish', this.handleAnimationFinish);
this.animation = null;
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. */
@Method()
async cancel() {
cancel() {
try {
this.animation.cancel();
} catch {}
}
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
@Method()
async finish() {
finish() {
try {
this.animation.finish();
} 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. */
@Method()
async getCurrentTime() {
getCurrentTime() {
return this.animation.currentTime;
}
/** Sets the current time of the animation in milliseconds. */
@Method()
async setCurrentTime(time: number) {
setCurrentTime(time: number) {
this.animation.currentTime = time;
}
render() {
return <slot onSlotchange={this.handleSlotChange} />;
return html`
<slot ref=${(el: HTMLSlotElement) => (this.defaultSlot = el)} onslotchange=${this.handleSlotChange.bind(this)} />
`;
}
}

Wyświetl plik

@ -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);
}

Wyświetl plik

@ -1 +0,0 @@
export * as animations from '@shoelace-style/animations';

Wyświetl plik

@ -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)'
};

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
/**
* @prop --size: The size of the avatar.

Wyświetl plik

@ -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>
`;
}
}

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: inline-flex;

Wyświetl plik

@ -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
@ -8,33 +9,27 @@ import { Component, Prop, h } from '@stencil/core';
*
* @part base - The base wrapper
*/
@Component({
tag: 'sl-badge',
styleUrl: 'badge.scss',
shadow: true
})
export class Badge {
badge: HTMLElement;
export default class SlBadge extends Shoemaker {
static tag = 'sl-badge';
static props = ['type', 'pill', 'pulse'];
static reflect = ['type', 'pill', 'pulse'];
static styles = styles;
/** 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. */
@Prop() pill = false;
/** Draws a pill-style badge with rounded edges. */
pill = false;
/** Set to true to make the badge pulsate to draw attention. */
@Prop() pulse = false;
/** Makes the badge pulsate to draw attention. */
pulse = false;
render() {
return (
return html`
<span
ref={el => (this.badge = el)}
part="base"
class={{
class=${classMap({
badge: true,
// Types
'badge--primary': this.type === 'primary',
'badge--success': this.type === 'success',
'badge--info': this.type === 'info',
@ -42,11 +37,11 @@ export class Badge {
'badge--danger': this.type === 'danger',
'badge--pill': this.pill,
'badge--pulse': this.pulse
}}
})}
role="status"
>
<slot />
</span>
);
`;
}
}

Wyświetl plik

@ -36,7 +36,7 @@ sl-button-group {
margin-left: calc(-1 * var(--sl-input-border-width));
// 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: '';
position: absolute;
top: 0;

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: inline-block;

Wyświetl plik

@ -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
@ -9,28 +10,25 @@ import { Component, Prop, h } from '@stencil/core';
* @part base - The component's base wrapper.
*/
@Component({
tag: 'sl-button-group',
styleUrl: 'button-group.scss',
shadow: true
})
export class ButtonGroup {
buttonGroup: HTMLElement;
export default class SlButtonGroup extends Shoemaker {
static tag = 'sl-button-group';
static props = ['label'];
static styles = styles;
private buttonGroup: HTMLElement;
/** A label to use for the button group's `aria-label` attribute. */
@Prop() label = '';
label = '';
connectedCallback() {
onReady() {
this.handleFocus = this.handleFocus.bind(this);
this.handleBlur = this.handleBlur.bind(this);
}
componentDidLoad() {
this.buttonGroup.addEventListener('sl-focus', this.handleFocus);
this.buttonGroup.addEventListener('sl-blur', this.handleBlur);
}
disconnectedCallback() {
onDisconnect() {
this.buttonGroup.removeEventListener('sl-focus', this.handleFocus);
this.buttonGroup.removeEventListener('sl-blur', this.handleBlur);
}
@ -46,10 +44,15 @@ export class ButtonGroup {
}
render() {
return (
<div ref={el => (this.buttonGroup = el)} part="base" class="button-group" aria-label={this.label}>
return html`
<div
ref=${(el: HTMLElement) => (this.buttonGroup = el)}
part="base"
class="button-group"
aria-label=${this.label}
>
<slot />
</div>
);
`;
}
}

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: inline-block;

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
/**
* @prop --border-color: The card's border color, including borders that occur inside the card.

Wyświetl plik

@ -1,5 +1,6 @@
import { Component, Element, State, h } from '@stencil/core';
import { hasSlot } from '../../utilities/slot';
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
import styles from 'sass:./card.scss';
import { hasSlot } from '../../internal/slot';
/**
* @since 2.0
@ -16,50 +17,42 @@ import { hasSlot } from '../../utilities/slot';
* @part body - The card's body.
* @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({
tag: 'sl-card',
styleUrl: 'card.scss',
shadow: true
})
export class Card {
@Element() host: HTMLSlCardElement;
private hasFooter = false;
private hasImage = false;
private hasHeader = false;
@State() hasFooter = false;
@State() hasImage = false;
@State() hasHeader = false;
connectedCallback() {
this.handleSlotChange = this.handleSlotChange.bind(this);
}
componentWillLoad() {
onConnect() {
this.handleSlotChange();
}
handleSlotChange() {
this.hasFooter = hasSlot(this.host, 'footer');
this.hasImage = hasSlot(this.host, 'image');
this.hasHeader = hasSlot(this.host, 'header');
this.hasFooter = hasSlot(this, 'footer');
this.hasImage = hasSlot(this, 'image');
this.hasHeader = hasSlot(this, 'header');
}
render() {
return (
return html`
<div
part="base"
class={{
class=${classMap({
card: true,
'card--has-footer': this.hasFooter,
'card--has-image': this.hasImage,
'card--has-header': this.hasHeader
}}
})}
>
<div part="image" class="card__image">
<slot name="image" onSlotchange={this.handleSlotChange} />
<slot name="image" onslotchange=${this.handleSlotChange.bind(this)} />
</div>
<div part="header" class="card__header">
<slot name="header" onSlotchange={this.handleSlotChange} />
<slot name="header" onslotchange=${this.handleSlotChange.bind(this)} />
</div>
<div part="body" class="card__body">
@ -67,9 +60,9 @@ export class Card {
</div>
<div part="footer" class="card__footer">
<slot name="footer" onSlotchange={this.handleSlotChange} />
<slot name="footer" onslotchange=${this.handleSlotChange.bind(this)} />
</div>
</div>
);
`;
}
}

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: inline-block;

Wyświetl plik

@ -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>
`;
}
}

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -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');
});
});

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
/**
* @prop --grid-width: The width of the color grid.
@ -142,7 +142,7 @@
margin-left: var(--sl-spacing-small);
cursor: copy;
&::before {
&:before {
content: '';
position: absolute;
top: 0;
@ -175,7 +175,7 @@
width: calc(var(--sl-input-height-small) / 2);
height: calc(var(--sl-input-height-small) / 2);
color: white;
background-color: rgba(0, 0, 0, 0.33);
background-color: var(--sl-color-gray-900);
border-radius: var(--sl-border-radius-circle);
opacity: 0;
@ -186,22 +186,22 @@
@keyframes copied {
0% {
transform: scale(0.5);
transform: scale(0.8);
opacity: 0;
}
25% {
30% {
transform: scale(1.2);
opacity: 1;
}
50% {
transform: scale(1);
70% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(1.6);
transform: scale(1.4);
opacity: 0;
}
}
@ -313,7 +313,7 @@
border-radius: var(--sl-border-radius-circle);
}
&::before {
&:before {
content: '';
position: absolute;
top: 0;
@ -334,7 +334,7 @@
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-focus-ring-color-primary);
outline: none;
&::before {
&:before {
box-shadow: inset 0 0 0 1px var(--sl-color-primary-500);
}
}

Wyświetl plik

@ -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 { clamp } from '../../utilities/math';
import { clamp } from '../../internal/math';
/**
* @since 2.0
* @status stable
*
* @dependency sl-button
* @dependency sl-dropdown
* @dependency sl-icon
* @dependency sl-input
*
* @part base - The component's base wrapper.
* @part trigger - The color picker's dropdown trigger.
* @part swatches - The container that holds swatches.
@ -19,79 +26,98 @@ import { clamp } from '../../utilities/math';
* @part preview - The preview color.
* @part input - The text input.
* @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({
tag: 'sl-color-picker',
styleUrl: 'color-picker.scss',
shadow: true
})
export class ColorPicker {
bypassValueParse = false;
dropdown: HTMLSlDropdownElement;
input: HTMLSlInputElement;
lastValueEmitted: string;
menu: HTMLElement;
previewButton: HTMLButtonElement;
trigger: HTMLButtonElement;
@Element() host: HTMLSlColorPickerElement;
@State() inputValue = '';
@State() hue = 0;
@State() saturation = 100;
@State() lightness = 100;
@State() alpha = 100;
@State() showCopyFeedback = false;
private alpha = 100;
private bypassValueParse = false;
private dropdown: SlDropdown;
private hue = 0;
private input: SlInput;
private inputValue = '';
private lastValueEmitted: string;
private lightness = 100;
private previewButton: HTMLButtonElement;
private saturation = 100;
private showCopyFeedback = false;
/** 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
* respectively. The color picker will always accept user input in any format (including CSS color names) and convert
* 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. */
@Prop() inline = false;
/** Renders the color picker inline rather than inside a dropdown. */
inline = false;
/** 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. */
@Prop() noFormatToggle = false;
noFormatToggle = false;
/** The input's name attribute. */
@Prop({ reflect: true }) name = '';
name = '';
/** Set to true to disable the color picker. */
@Prop() disabled = false;
/** Disables the color picker. */
disabled = false;
/**
* 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.
*/
@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
* `overflow: auto|scroll`.
*/
@Prop() hoist = false;
hoist = false;
/** 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. */
@Prop() uppercase = false;
uppercase = false;
/**
* 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.
*/
@Prop() swatches = [
swatches = [
'#d0021b',
'#f5a623',
'#f8e71c',
@ -110,77 +136,7 @@ export class ColorPicker {
'#fff'
];
/** Emitted when the color picker's value changes. */
@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() {
onReady() {
if (!this.setColor(this.value)) {
this.setColor(`#ffff`);
}
@ -191,8 +147,7 @@ export class ColorPicker {
}
/** Returns the current value as a string in the specified format. */
@Method()
async getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' = 'hex') {
getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' = 'hex') {
const currentColor = this.parseColor(
`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. */
@Method()
async reportValidity() {
reportValidity() {
// If the input is invalid, show the dropdown so the browser can focus on it
if (!this.inline && this.input.invalid) {
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. */
@Method()
async setCustomValidity(message: string) {
await this.input.setCustomValidity(message);
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.invalid = this.input.invalid;
}
handleCopy() {
this.input.select().then(() => {
document.execCommand('copy');
this.previewButton.focus();
this.showCopyFeedback = true;
this.previewButton.addEventListener('animationend', () => (this.showCopyFeedback = false), { once: true });
});
this.input.select();
document.execCommand('copy');
this.previewButton.focus();
this.showCopyFeedback = true;
this.previewButton.addEventListener('animationend', () => (this.showCopyFeedback = false), { once: true });
}
handleFormatToggle() {
@ -262,28 +214,8 @@ export class ColorPicker {
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) {
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 { width } = container.getBoundingClientRect();
@ -297,7 +229,7 @@ export class ColorPicker {
}
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 { width } = container.getBoundingClientRect();
@ -311,7 +243,7 @@ export class ColorPicker {
}
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 { width, height } = grid.getBoundingClientRect();
@ -327,13 +259,14 @@ export class ColorPicker {
handleDrag(event: any, container: HTMLElement, onMove: (x: number, y: number) => void) {
if (this.disabled) {
return false;
return;
}
const move = (event: any) => {
const dims = container.getBoundingClientRect();
const offsetX = dims.left + container.ownerDocument.defaultView.pageXOffset;
const offsetY = dims.top + container.ownerDocument.defaultView.pageYOffset;
const defaultView = container.ownerDocument.defaultView!;
const offsetX = dims.left + defaultView.pageXOffset;
const offsetY = dims.top + defaultView.pageYOffset;
const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX;
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) {
event.stopPropagation();
this.slShow.emit();
this.emit('sl-show');
}
handleDropdownAfterShow(event: CustomEvent) {
event.stopPropagation();
this.slAfterShow.emit();
this.emit('sl-after-show');
}
handleDropdownHide(event: CustomEvent) {
event.stopPropagation();
this.slHide.emit();
this.emit('sl-hide');
}
handleDropdownAfterHide(event: CustomEvent) {
event.stopPropagation();
this.slAfterHide.emit();
this.emit('sl-after-hide');
this.showCopyFeedback = false;
}
@ -641,7 +565,7 @@ export class ColorPicker {
);
if (!currentColor) {
return false;
return;
}
// Update the value
@ -660,212 +584,245 @@ export class ColorPicker {
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() {
const x = this.saturation;
const y = 100 - this.lightness;
const ColorPicker = () => {
return (
const colorPicker = html`
<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
part="base"
class={{
'color-picker': true,
'color-picker--inline': this.inline,
'color-picker--disabled': this.disabled
}}
aria-disabled={this.disabled ? 'true' : 'false'}
part="grid"
class="color-picker__grid"
style=${styleMap({ backgroundColor: `hsl(${this.hue}deg, 100%, 50%)` })}
onmousedown=${this.handleGridDrag.bind(this)}
ontouchstart=${this.handleGridDrag.bind(this)}
>
<div
part="grid"
class="color-picker__grid"
style={{
backgroundColor: `hsl(${this.hue}deg, 100%, 50%)`
}}
onMouseDown={this.handleGridDrag}
onTouchStart={this.handleGridDrag}
>
<span
part="grid-handle"
class="color-picker__grid-handle"
style={{
top: `${y}%`,
left: `${x}%`,
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>
)}
<span
part="grid-handle"
class="color-picker__grid-handle"
style=${styleMap({
top: `${y}%`,
left: `${x}%`,
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.bind(this)}
/>
</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
if (this.inline) {
return <ColorPicker />;
return colorPicker;
}
// Render as a dropdown
return (
return html`
<sl-dropdown
ref={el => (this.dropdown = el)}
ref=${(el: SlDropdown) => (this.dropdown = el)}
class="color-dropdown"
aria-disabled={this.disabled ? 'true' : 'false'}
containingElement={this.host}
hoist={this.hoist}
onSl-show={this.handleDropdownShow}
onSl-after-show={this.handleDropdownAfterShow}
onSl-hide={this.handleDropdownHide}
onSl-after-hide={this.handleDropdownAfterHide}
aria-disabled=${this.disabled ? 'true' : 'false'}
.containing-element=${this}
hoist=${this.hoist}
onsl-show=${this.handleDropdownShow.bind(this)}
onsl-after-show=${this.handleDropdownAfterShow.bind(this)}
onsl-hide=${this.handleDropdownHide.bind(this)}
onsl-after-hide=${this.handleDropdownAfterHide.bind(this)}
>
<button
ref={el => (this.trigger = el)}
part="trigger"
slot="trigger"
class={{
class=${classMap({
'color-dropdown__trigger': true,
'color-dropdown__trigger--disabled': this.disabled,
'color-dropdown__trigger--small': this.size === 'small',
'color-dropdown__trigger--medium': this.size === 'medium',
'color-dropdown__trigger--large': this.size === 'large',
'color-picker__transparent-bg': true
}}
style={{
})}
style=${styleMap({
color: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
}}
})}
type="button"
/>
<ColorPicker />
${colorPicker}
</sl-dropdown>
);
`;
}
}

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
/**
* @prop --hide-duration: The length of the hide transition.

Wyświetl plik

@ -1,5 +1,6 @@
import { Component, Event, EventEmitter, Method, Prop, Watch, h } from '@stencil/core';
import { focusVisible } from '../../utilities/focus-visible';
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
import styles from 'sass:./details.scss';
import { focusVisible } from '../../internal/focus-visible';
let id = 0;
@ -7,6 +8,8 @@ let id = 0;
* @since 2.0
* @status stable
*
* @dependency sl-icon
*
* @slot - The details' content.
* @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-icon - The expand/collapse summary icon.
* @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({
tag: 'sl-details',
styleUrl: 'details.scss',
shadow: true
})
export class Details {
body: HTMLElement;
componentId = `details-${++id}`;
details: HTMLElement;
header: HTMLElement;
isVisible = false;
private body: HTMLElement;
private componentId = `details-${++id}`;
private details: HTMLElement;
private header: HTMLElement;
private isVisible = false;
/** 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. */
@Prop() summary = '';
summary = '';
/** Set to true to prevent the user from toggling the details. */
@Prop() disabled = false;
/** Disables the details so it can't be toggled. */
disabled = false;
@Watch('open')
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() {
onReady() {
focusVisible.observe(this.details);
this.body.hidden = !this.open;
@ -72,19 +56,18 @@ export class Details {
}
}
disconnectedCallback() {
onDisconnect() {
focusVisible.unobserve(this.details);
}
/** Shows the alert. */
@Method()
async show() {
show() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (this.isVisible) {
return;
}
const slShow = this.slShow.emit();
const slShow = this.emit('sl-show');
if (slShow.defaultPrevented) {
this.open = false;
return;
@ -107,14 +90,13 @@ export class Details {
}
/** Hides the alert */
@Method()
async hide() {
hide() {
// Prevent subsequent calls to the method, whether manually or triggered by the `open` watcher
if (!this.isVisible) {
return;
}
const slHide = this.slHide.emit();
const slHide = this.emit('sl-hide');
if (slHide.defaultPrevented) {
this.open = true;
return;
@ -140,7 +122,7 @@ export class Details {
if (event.propertyName === 'height' && target.classList.contains('details__body')) {
this.body.style.overflow = this.open ? 'visible' : 'hidden';
this.body.style.height = this.open ? 'auto' : '0';
this.open ? this.slAfterShow.emit() : this.slAfterHide.emit();
this.open ? this.emit('sl-after-show') : this.emit('sl-after-hide');
this.body.hidden = !this.open;
}
}
@ -169,32 +151,36 @@ export class Details {
}
}
watchOpen() {
this.open ? this.show() : this.hide();
}
render() {
return (
return html`
<div
ref={el => (this.details = el)}
ref=${(el: HTMLElement) => (this.details = el)}
part="base"
class={{
class=${classMap({
details: true,
'details--open': this.open,
'details--disabled': this.disabled
}}
})}
>
<header
ref={el => (this.header = el)}
ref=${(el: HTMLElement) => (this.header = el)}
part="header"
id={`${this.componentId}-header`}
id=${`${this.componentId}-header`}
class="details__header"
role="button"
aria-expanded={this.open ? 'true' : 'false'}
aria-controls={`${this.componentId}-content`}
aria-disabled={this.disabled ? 'true' : 'false'}
tabIndex={this.disabled ? -1 : 0}
onClick={this.handleSummaryClick}
onKeyDown={this.handleSummaryKeyDown}
aria-expanded=${this.open ? 'true' : 'false'}
aria-controls=${`${this.componentId}-content`}
aria-disabled=${this.disabled ? 'true' : 'false'}
tabindex=${this.disabled ? '-1' : '0'}
onclick=${this.handleSummaryClick.bind(this)}
onkeydown=${this.handleSummaryKeyDown.bind(this)}
>
<div part="summary" class="details__summary">
<slot name="summary">{this.summary}</slot>
<slot name="summary">${this.summary}</slot>
</div>
<span part="summary-icon" class="details__summary-icon">
@ -202,18 +188,22 @@ export class Details {
</span>
</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
part="content"
id={`${this.componentId}-content`}
id=${`${this.componentId}-content`}
class="details__content"
role="region"
aria-labelledby={`${this.componentId}-header`}
aria-labelledby=${`${this.componentId}-header`}
>
<slot />
</div>
</div>
</div>
);
`;
}
}

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -1,5 +1,5 @@
@import 'component';
@import 'mixins/hidden';
@use '../../styles/component';
@use '../../styles/mixins/hide';
/**
* @prop --width: The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
@ -22,7 +22,7 @@
z-index: var(--sl-z-index-dialog);
&:not(.dialog--visible) {
@include hidden;
@include hide.hidden;
}
}

Wyświetl plik

@ -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>
`;
}
}

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -1,5 +1,5 @@
@import 'component';
@import 'mixins/hidden';
@use '../../styles/component';
@use '../../styles/mixins/hide';
/**
* @prop --size: The preferred size of the drawer. This will be applied to the drawer's width or height depending on its
@ -20,7 +20,7 @@
overflow: hidden;
&:not(.drawer--visible) {
@include hidden;
@include hide.hidden;
}
}

Wyświetl plik

@ -1,8 +1,9 @@
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';
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
import styles from 'sass:./drawer.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();
@ -12,6 +13,8 @@ let id = 0;
* @since 2.0
* @status stable
*
* @dependency sl-icon-button
*
* @slot - The drawer's content.
* @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.
@ -24,88 +27,60 @@ let id = 0;
* @part close-button - The close button.
* @part body - The drawer body.
* @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({
tag: 'sl-drawer',
styleUrl: 'drawer.scss',
shadow: true
})
export class Drawer {
componentId = `drawer-${++id}`;
drawer: HTMLElement;
modal: Modal;
panel: HTMLElement;
willShow = false;
willHide = false;
export default class SlDrawer extends Shoemaker {
static tag = 'sl-drawer';
static props = ['hasFooter', 'isVisible', 'open', 'label', 'placement', 'contained', 'noHeader'];
static reflect = ['open'];
static styles = styles;
@Element() host: HTMLSlDrawerElement;
@State() hasFooter = false;
@State() isVisible = false;
private componentId = `drawer-${++id}`;
private drawer: HTMLElement;
private hasFooter = 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. */
@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
* `no-header`, as it is required for proper accessibility.
*/
@Prop() label = '';
label = '';
/** 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
* 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,
* accessible way for users to dismiss the drawer.
*/
@Prop() noHeader = false;
noHeader = false;
@Watch('open')
handleOpenChange() {
this.open ? this.show() : this.hide();
}
/** 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())
onConnect() {
this.modal = new Modal(this, {
onfocusOut: () => (this.contained ? null : this.panel.focus())
});
}
componentWillLoad() {
this.handleSlotChange();
// Show on init if open
@ -114,18 +89,17 @@ export class Drawer {
}
}
disconnectedCallback() {
unlockBodyScrolling(this.host);
onDisconnect() {
unlockBodyScrolling(this);
}
/** Shows the drawer */
@Method()
async show() {
show() {
if (this.willShow) {
return;
}
const slShow = this.slShow.emit();
const slShow = this.emit('sl-show');
if (slShow.defaultPrevented) {
this.open = false;
return;
@ -138,14 +112,14 @@ export class Drawer {
// Lock body scrolling only if the drawer isn't contained
if (!this.contained) {
this.modal.activate();
lockBodyScrolling(this.host);
lockBodyScrolling(this);
}
if (this.open) {
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(() => {
const slInitialFocus = this.slInitialFocus.emit();
const slInitialFocus = this.emit('sl-initial-focus');
if (!slInitialFocus.defaultPrevented) {
this.panel.focus({ preventScroll: true });
}
@ -161,7 +135,7 @@ export class Drawer {
this.drawer.addEventListener(
'transitionend',
() => {
const slInitialFocus = this.slInitialFocus.emit();
const slInitialFocus = this.emit('sl-initial-focus');
if (!slInitialFocus.defaultPrevented) {
this.panel.focus();
}
@ -173,13 +147,12 @@ export class Drawer {
}
/** Hides the drawer */
@Method()
async hide() {
hide() {
if (this.willHide) {
return;
}
const slHide = this.slHide.emit();
const slHide = this.emit('sl-hide');
if (slHide.defaultPrevented) {
this.open = true;
return;
@ -189,7 +162,7 @@ export class Drawer {
this.open = false;
this.modal.deactivate();
unlockBodyScrolling(this.host);
unlockBodyScrolling(this);
}
handleCloseClick() {
@ -203,7 +176,7 @@ export class Drawer {
}
handleOverlayClick() {
const slOverlayDismiss = this.slOverlayDismiss.emit();
const slOverlayDismiss = this.emit('sl-overlay-dismiss');
if (!slOverlayDismiss.defaultPrevented) {
this.hide();
@ -211,7 +184,7 @@ export class Drawer {
}
handleSlotChange() {
this.hasFooter = hasSlot(this.host, 'footer');
this.hasFooter = hasSlot(this, 'footer');
}
handleTransitionEnd(event: TransitionEvent) {
@ -222,16 +195,16 @@ export class Drawer {
this.isVisible = this.open;
this.willShow = 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() {
return (
return html`
<div
ref={el => (this.drawer = el)}
ref=${(el: HTMLElement) => (this.drawer = el)}
part="base"
class={{
class=${classMap({
drawer: true,
'drawer--open': this.open,
'drawer--visible': this.isVisible,
@ -242,49 +215,49 @@ export class Drawer {
'drawer--contained': this.contained,
'drawer--fixed': !this.contained,
'drawer--has-footer': this.hasFooter
}}
onKeyDown={this.handleKeyDown}
onTransitionEnd={this.handleTransitionEnd}
})}
onkeydown=${this.handleKeyDown.bind(this)}
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
ref={el => (this.panel = el)}
ref=${(el: HTMLElement) => (this.panel = el)}
part="panel"
class="drawer__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}
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="drawer__header">
<span part="title" class="drawer__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="drawer__close"
name="x"
onClick={this.handleCloseClick}
/>
</header>
)}
${!this.noHeader
? html`
<header part="header" class="drawer__header">
<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 -->
<slot name="label"> ${this.label || String.fromCharCode(65279)} </slot>
</span>
<sl-icon-button
exportparts="base:close-button"
class="drawer__close"
name="x"
onclick=${this.handleCloseClick.bind(this)}
/>
</header>
`
: ''}
<div part="body" class="drawer__body">
<slot />
</div>
<footer part="footer" class="drawer__footer">
<slot name="footer" onSlotchange={this.handleSlotChange} />
<slot name="footer" onslotchange=${this.handleSlotChange.bind(this)} />
</footer>
</div>
</div>
);
`;
}
}

Wyświetl plik

@ -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);
});
});

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: inline-block;
@ -18,7 +18,7 @@
}
.dropdown__panel {
max-height: 50vh;
max-height: 75vh;
font-family: var(--sl-font-sans);
font-size: var(--sl-font-size-medium);
font-weight: var(--sl-font-weight-normal);

Wyświetl plik

@ -1,7 +1,9 @@
import { Component, Element, Event, EventEmitter, Method, Prop, Watch, h } from '@stencil/core';
import { scrollIntoView } from '../../utilities/scroll';
import { getNearestTabbableElement } from '../../utilities/tabbable';
import Popover from '../../utilities/popover';
import { classMap, html, Shoemaker } from '@shoelace-style/shoemaker';
import styles from 'sass:./dropdown.scss';
import { SlMenu, SlMenuItem } from '../../shoelace';
import { scrollIntoView } from '../../internal/scroll';
import { getNearestTabbableElement } from '../../internal/tabbable';
import Popover from '../../internal/popover';
let id = 0;
@ -15,32 +17,33 @@ let id = 0;
* @part base - The component's base wrapper.
* @part trigger - The container that wraps the trigger.
* @part panel - The panel that gets shown when the dropdown is open.
*
* @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({
tag: 'sl-dropdown',
styleUrl: 'dropdown.scss',
shadow: true
})
export class Dropdown {
accessibleTrigger: HTMLElement;
componentId = `dropdown-${++id}`;
isVisible = false;
panel: HTMLElement;
positioner: HTMLElement;
popover: Popover;
trigger: HTMLElement;
@Element() host: HTMLSlDropdownElement;
private componentId = `dropdown-${++id}`;
private isVisible = false;
private panel: HTMLElement;
private positioner: HTMLElement;
private popover: Popover;
private trigger: HTMLElement;
/** 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
* inside of the viewport.
*/
@Prop() placement:
placement:
| 'top'
| 'top-start'
| 'top-end'
@ -55,45 +58,23 @@ export class Dropdown {
| 'left-end' = 'bottom-start';
/** 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). */
@Prop() containingElement: HTMLElement;
containingElement: HTMLElement;
/** 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. */
@Prop() skidding = 0;
skidding = 0;
/**
* Enable this option to prevent the panel from being clipped when the component is placed inside a container with
* `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() {
this.popover.setOptions({
strategy: this.hoist ? 'fixed' : 'absolute',
@ -103,30 +84,26 @@ export class Dropdown {
});
}
connectedCallback() {
if (!this.containingElement) {
this.containingElement = this.host;
}
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
onConnect() {
this.handleMenuItemActivate = this.handleMenuItemActivate.bind(this);
this.handlePanelSelect = this.handlePanelSelect.bind(this);
this.handleTriggerClick = this.handleTriggerClick.bind(this);
this.handleTriggerKeyDown = this.handleTriggerKeyDown.bind(this);
this.handleTriggerKeyUp = this.handleTriggerKeyUp.bind(this);
this.handleTriggerSlotChange = this.handleTriggerSlotChange.bind(this);
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
if (!this.containingElement) {
this.containingElement = this;
}
}
componentDidLoad() {
onReady() {
this.popover = new Popover(this.trigger, this.positioner, {
strategy: this.hoist ? 'fixed' : 'absolute',
placement: this.placement,
distance: this.distance,
skidding: this.skidding,
transitionElement: this.panel,
onAfterHide: () => this.slAfterHide.emit(),
onAfterShow: () => this.slAfterShow.emit(),
onAfterHide: () => this.emit('sl-after-hide'),
onAfterShow: () => this.emit('sl-after-show'),
onTransitionEnd: () => {
if (!this.open) {
this.panel.scrollTop = 0;
@ -140,71 +117,13 @@ export class Dropdown {
}
}
disconnectedCallback() {
onDisconnect() {
this.hide();
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() {
const slot = this.trigger.querySelector('slot');
const slot = this.trigger.querySelector('slot')!;
const trigger = slot.assignedElements({ flatten: true })[0] as any;
if (trigger) {
if (typeof trigger.setFocus === 'function') {
@ -216,10 +135,8 @@ export class Dropdown {
}
getMenu() {
return this.panel
.querySelector('slot')
.assignedElements({ flatten: true })
.filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as HTMLSlMenuElement;
const slot = this.panel.querySelector('slot')!;
return slot.assignedElements({ flatten: true }).filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as SlMenu;
}
handleDocumentKeyDown(event: KeyboardEvent) {
@ -247,7 +164,7 @@ export class Dropdown {
setTimeout(() => {
const activeElement =
this.containingElement.getRootNode() instanceof ShadowRoot
? document.activeElement.shadowRoot?.activeElement
? document.activeElement?.shadowRoot?.activeElement
: document.activeElement;
if (activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement) {
@ -268,7 +185,7 @@ export class Dropdown {
}
handleMenuItemActivate(event: CustomEvent) {
const item = event.target as HTMLSlMenuItemElement;
const item = event.target as SlMenuItem;
scrollIntoView(item, this.panel);
}
@ -288,7 +205,7 @@ export class Dropdown {
handleTriggerKeyDown(event: KeyboardEvent) {
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 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() {
return (
return html`
<div
part="base"
id={this.componentId}
class={{
id=${this.componentId}
class=${classMap({
dropdown: true,
'dropdown--open': this.open
}}
})}
>
<span
part="trigger"
class="dropdown__trigger"
ref={el => (this.trigger = el)}
onClick={this.handleTriggerClick}
onKeyDown={this.handleTriggerKeyDown}
onKeyUp={this.handleTriggerKeyUp}
ref=${(el: HTMLElement) => (this.trigger = el)}
onclick=${this.handleTriggerClick.bind(this)}
onkeydown=${this.handleTriggerKeyDown.bind(this)}
onkeyup=${this.handleTriggerKeyUp.bind(this)}
>
<slot name="trigger" onSlotchange={this.handleTriggerSlotChange} />
<slot name="trigger" onslotchange=${this.handleTriggerSlotChange.bind(this)} />
</span>
{/* 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. */}
<div ref={el => (this.positioner = el)} class="dropdown__positioner">
<!-- 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. -->
<div ref=${(el: HTMLElement) => (this.positioner = el)} class="dropdown__positioner">
<div
ref={el => (this.panel = el)}
ref=${(el: HTMLElement) => (this.panel = el)}
part="panel"
class="dropdown__panel"
role="menu"
aria-hidden={this.open ? 'false' : 'true'}
aria-labelledby={this.componentId}
aria-hidden=${this.open ? 'false' : 'true'}
aria-labelledby=${this.componentId}
>
<slot />
</div>
</div>
</div>
);
`;
}
}

Wyświetl plik

@ -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']
]);
});
});

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: block;

Wyświetl plik

@ -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 {
tag: string;
@ -14,29 +26,25 @@ interface FormControl {
* @slot - The form's content.
*
* @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({
tag: 'sl-form',
styleUrl: 'form.scss',
shadow: true
})
export class Form {
form: HTMLElement;
formControls: FormControl[];
private form: HTMLElement;
private formControls: FormControl[];
/** Prevent the form from validating inputs before submitting. */
@Prop() novalidate = false;
novalidate = false;
/**
* 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() {
onConnect() {
this.formControls = [
{
tag: 'button',
@ -61,7 +69,7 @@ export class Form {
}
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;
}
@ -103,10 +111,9 @@ export class Form {
},
{
tag: 'sl-button',
serialize: (el: HTMLSlButtonElement, formData) =>
el.name && !el.disabled ? formData.append(el.name, el.value) : null,
serialize: (el: SlButton, formData) => (el.name && !el.disabled ? formData.append(el.name, el.value) : null),
click: event => {
const target = event.target as HTMLSlButtonElement;
const target = event.target as SlButton;
if (target.submit) {
this.submit();
}
@ -114,18 +121,17 @@ export class Form {
},
{
tag: 'sl-checkbox',
serialize: (el: HTMLSlCheckboxElement, formData) =>
serialize: (el: SlCheckbox, formData) =>
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
},
{
tag: 'sl-color-picker',
serialize: (el: HTMLSlCheckboxElement, formData) =>
serialize: (el: SlColorPicker, formData) =>
el.name && !el.disabled ? formData.append(el.name, el.value) : null
},
{
tag: 'sl-input',
serialize: (el: HTMLSlInputElement, formData) =>
el.name && !el.disabled ? formData.append(el.name, el.value) : null,
serialize: (el: SlInput, formData) => (el.name && !el.disabled ? formData.append(el.name, el.value) : null),
keyDown: event => {
if (event.key === 'Enter' && !event.defaultPrevented) {
this.submit();
@ -134,12 +140,12 @@ export class Form {
},
{
tag: 'sl-radio',
serialize: (el: HTMLSlRadioElement, formData) =>
serialize: (el: SlRadio, formData) =>
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
},
{
tag: 'sl-range',
serialize: (el: HTMLSlRangeElement, formData) => {
serialize: (el: SlRange, formData) => {
if (el.name && !el.disabled) {
formData.append(el.name, el.value + '');
}
@ -147,7 +153,7 @@ export class Form {
},
{
tag: 'sl-select',
serialize: (el: HTMLSlSelectElement, formData) => {
serialize: (el: SlSelect, formData) => {
if (el.name && !el.disabled) {
if (el.multiple) {
const selectedOptions = [...el.value];
@ -164,13 +170,12 @@ export class Form {
},
{
tag: 'sl-switch',
serialize: (el: HTMLSlSwitchElement, formData) =>
serialize: (el: SlSwitch, formData) =>
el.name && el.checked && !el.disabled ? formData.append(el.name, el.value) : null
},
{
tag: 'sl-textarea',
serialize: (el: HTMLSlTextareaElement, formData) =>
el.name && !el.disabled ? formData.append(el.name, el.value) : null
serialize: (el: SlTextarea, formData) => (el.name && !el.disabled ? formData.append(el.name, el.value) : null)
},
{
tag: 'textarea',
@ -184,10 +189,9 @@ export class Form {
}
/** Serializes all form controls elements and returns a `FormData` object. */
@Method()
async getFormData() {
getFormData() {
const formData = new FormData();
const formControls = await this.getFormControls();
const formControls = this.getFormControls();
formControls.map(el => this.serializeElement(el, formData));
@ -195,29 +199,30 @@ export class Form {
}
/** Gets all form control elements (native and custom). */
@Method()
async getFormControls() {
const slot = this.form.querySelector('slot');
getFormControls() {
const slot = this.form.querySelector('slot')!;
const tags = this.formControls.map(control => control.tag);
return slot
.assignedElements({ flatten: true })
.reduce((all, el) => all.concat(el, [...el.querySelectorAll('*')]), [])
.filter(el => tags.includes(el.tagName.toLowerCase())) as HTMLElement[];
.reduce(
(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
* with `true`. If any form control is invalid, the promise will resolve with `false` and no event will be emitted.
*/
@Method()
async submit() {
const formData = await this.getFormData();
const formControls = await this.getFormControls();
submit() {
const formData = this.getFormData();
const formControls = this.getFormControls();
const formControlsThatReport = formControls.filter((el: any) => typeof el.reportValidity === 'function') as any;
if (!this.novalidate) {
for (const el of formControlsThatReport) {
const isValid = await el.reportValidity();
const isValid = el.reportValidity();
if (!isValid) {
return false;
@ -225,7 +230,7 @@ export class Form {
}
}
this.slSubmit.emit({ formData, formControls });
this.emit('sl-submit', { detail: { formData, formControls } });
return true;
}
@ -265,17 +270,17 @@ export class Form {
}
render() {
return (
return html`
<div
ref={el => (this.form = el)}
ref=${(el: HTMLElement) => (this.form = el)}
part="base"
class="form"
role="form"
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
onclick=${this.handleClick.bind(this)}
onkeydown=${this.handleKeyDown.bind(this)}
>
<slot />
</div>
);
`;
}
}

Wyświetl plik

@ -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
});
}
}

Wyświetl plik

@ -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
});
}
}

Wyświetl plik

@ -1,53 +1,65 @@
import { Component, Prop } from '@stencil/core';
import { Shoemaker } from '@shoelace-style/shoemaker';
/**
* @since 2.0
* @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. */
@Prop() date: Date | string = new Date();
date: Date | string = new Date();
/** The locale to use when formatting the date/time. */
@Prop() locale: string;
locale: string;
/** The format for displaying the weekday. */
@Prop() weekday: 'narrow' | 'short' | 'long';
weekday: 'narrow' | 'short' | 'long';
/** The format for displaying the era. */
@Prop() era: 'narrow' | 'short' | 'long';
era: 'narrow' | 'short' | 'long';
/** The format for displaying the year. */
@Prop() year: 'numeric' | '2-digit';
year: 'numeric' | '2-digit';
/** 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. */
@Prop() day: 'numeric' | '2-digit';
day: 'numeric' | '2-digit';
/** The format for displaying the hour. */
@Prop() hour: 'numeric' | '2-digit';
hour: 'numeric' | '2-digit';
/** The format for displaying the minute. */
@Prop() minute: 'numeric' | '2-digit';
minute: 'numeric' | '2-digit';
/** The format for displaying the second. */
@Prop() second: 'numeric' | '2-digit';
second: 'numeric' | '2-digit';
/** The format for displaying the time. */
@Prop() timeZoneName: 'short' | 'long';
timeZoneName: 'short' | 'long';
/** The time zone to express the time in. */
@Prop() timeZone: string;
timeZone: string;
/** When set, 24 hour time will always be used. */
@Prop() hourFormat: 'auto' | '12' | '24' = 'auto';
hourFormat: 'auto' | '12' | '24' = 'auto';
render() {
const date = new Date(this.date);

Wyświetl plik

@ -1,47 +1,57 @@
import { Component, Prop } from '@stencil/core';
import { Shoemaker } from '@shoelace-style/shoemaker';
/**
* @since 2.0
* @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. */
@Prop() value = 0;
value = 0;
/** The locale to use when formatting the number. */
@Prop() locale: string;
locale: string;
/** The formatting style to use. */
@Prop() type: 'currency' | 'decimal' | 'percent' = 'decimal';
type: 'currency' | 'decimal' | 'percent' = 'decimal';
/** 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`. */
@Prop() currency = 'USD';
currency = 'USD';
/** 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. */
@Prop() minimumIntegerDigits: number;
minimumIntegerDigits: number;
/** 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. */
@Prop() maximumFractionDigits: number;
maximumFractionDigits: number;
/** 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. */
@Prop() maximumSignificantDigits: number;
maximumSignificantDigits: number;
render() {
if (isNaN(this.value)) {

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: inline-block;

Wyświetl plik

@ -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>
`;
}
}

Wyświetl plik

@ -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>
);
}
}

Wyświetl plik

@ -1,3 +0,0 @@
:host {
display: none;
}

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -1,4 +1,4 @@
@import 'component';
@use '../../styles/component';
:host {
display: inline-block;

Wyświetl plik

@ -1,5 +1,6 @@
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
import { getLibrary, watchIcon, unwatchIcon } from '../icon-library/icon-library-registry';
import { html, Hole, Shoemaker } from '@shoelace-style/shoemaker';
import styles from 'sass:./icon.scss';
import { getIconLibrary, watchIcon, unwatchIcon } from './library';
import { requestIcon } from './request';
const parser = new DOMParser();
@ -9,60 +10,39 @@ const parser = new DOMParser();
* @status stable
*
* @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({
tag: 'sl-icon',
styleUrl: 'icon.scss',
shadow: true,
assetsDirs: ['icons']
})
export class Icon {
@Element() host: HTMLSlIconElement;
@State() svg: string;
private svg: Hole | string;
/** The name of the icon to draw. */
@Prop() name: string;
name: string;
/** 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. */
@Prop() label: string;
label: string;
/** The name of a registered custom icon library. */
@Prop() library = 'default';
library = 'default';
/** Emitted when the icon has loaded. */
@Event({ eventName: 'sl-load' }) slLoad: EventEmitter;
onConnect() {
watchIcon(this);
}
/** Emitted when the icon failed to load. */
@Event({ eventName: 'sl-error' }) slError: EventEmitter<{ status?: number }>;
@Watch('name')
@Watch('src')
@Watch('library')
handleChange() {
onReady() {
this.setIcon();
}
connectedCallback() {
watchIcon(this.host);
}
componentDidLoad() {
this.setIcon();
}
disconnectedCallback() {
unwatchIcon(this.host);
}
/** @internal Fetches the icon and redraws it. Used to handle library registrations. */
@Method()
async redraw() {
this.setIcon();
onDisconnect() {
unwatchIcon(this);
}
getLabel() {
@ -79,8 +59,13 @@ export class Icon {
return label;
}
/** @internal Fetches the icon and redraws it. Used to handle library registrations. */
redraw() {
this.setIcon();
}
async setIcon() {
const library = getLibrary(this.library);
const library = getIconLibrary(this.library);
let url = this.src;
if (this.name && library) {
@ -89,35 +74,52 @@ export class Icon {
if (url) {
try {
const file = await requestIcon(url);
const file = await requestIcon(url)!;
if (file.ok) {
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) {
library.mutator(svg);
library.mutator(svgEl);
}
this.svg = svg.outerHTML;
this.slLoad.emit();
this.svg = html([svgEl.outerHTML] as any);
this.emit('sl-load');
} else {
this.svg = '';
this.slError.emit({ status: file.status });
this.emit('sl-error', { detail: { status: file.status } });
}
} else {
this.slError.emit({ status: file.status });
this.svg = '';
this.emit('sl-error', { detail: { status: file.status } });
}
} catch {
this.slError.emit();
this.emit('sl-error');
}
} else if (this.svg) {
// 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() {
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>`;
}
}

Wyświetl plik

@ -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 IconLibraryMutator = (svg: SVGElement) => void;
interface IconLibraryRegistry {
name: string;
resolver: IconLibraryResolver;
@ -11,26 +13,33 @@ interface IconLibraryRegistry {
let registry: IconLibraryRegistry[] = [
{
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);
}
export function unwatchIcon(icon: HTMLSlIconElement) {
export function unwatchIcon(icon: 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];
}
export function registerLibrary(name: string, resolver: IconLibraryResolver, mutator?: IconLibraryMutator) {
unregisterLibrary(name);
registry.push({ name, resolver, mutator });
export function registerIconLibrary(
name: string,
options: { resolver: IconLibraryResolver; mutator?: IconLibraryMutator }
) {
unregisterIconLibrary(name);
registry.push({
name,
resolver: options.resolver,
mutator: options.mutator
});
// Redraw watched icons
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);
}

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