kopia lustrzana https://github.com/shoelace-style/shoelace
Merge branch 'next' into calendar
commit
1b24f73314
|
@ -1,4 +1,7 @@
|
|||
contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://github.com/shoelace-style/shoelace/discussions/categories/ideas
|
||||
about: All requests for new features should go here.
|
||||
- name: Help & Support
|
||||
url: https://github.com/shoelace-style/shoelace/discussions/categories/help
|
||||
about: Please don't create issues for personal help requests. Instead, ask your question on the discussion forum.
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for this project.
|
||||
title: ''
|
||||
labels: feature
|
||||
---
|
||||
|
||||
### What issue are you having?
|
||||
Provide a clear and concise description of the problem you're facing.
|
||||
|
||||
### Describe the solution you'd like
|
||||
How would you like to see the library solve it?
|
||||
|
||||
### Describe alternatives you've considered
|
||||
In what ways have you tried to solve this with the current version?
|
|
@ -25,6 +25,6 @@ jobs:
|
|||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- run: npx playwright install-deps
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
_site
|
||||
.cache
|
||||
.DS_Store
|
||||
package.json
|
||||
package-lock.json
|
||||
cdn
|
||||
dist
|
||||
docs/assets/images/sprite.svg
|
||||
|
|
12
README.md
12
README.md
|
@ -77,16 +77,6 @@ Shoelace is an open source project and contributions are encouraged! If you're i
|
|||
|
||||
## License
|
||||
|
||||
Shoelace is designed in New Hampshire by [Cory LaViska](https://twitter.com/claviska). It’s available under the terms of the MIT license.
|
||||
|
||||
Designing, developing, and supporting this library requires a lot of time, effort, and skill. I’d like to keep it open source so everyone can use it, but that doesn’t provide me with any income.
|
||||
|
||||
**Therefore, if you’re using my software to make a profit,** I respectfully ask that you help [fund its development](https://github.com/sponsors/claviska) by becoming a sponsor. There are multiple tiers to choose from with benefits at every level, including prioritized support, bug fixes, feature requests, and advertising.
|
||||
|
||||
👇 Your support is very much appreciated! 👇
|
||||
|
||||
- [Become a sponsor](https://github.com/sponsors/claviska)
|
||||
- [Star on GitHub](https://github.com/shoelace-style/shoelace/stargazers)
|
||||
- [Follow on Twitter](https://twitter.com/shoelace_style)
|
||||
Shoelace was created by [Cory LaViska](https://twitter.com/claviska) and is available under the terms of the MIT license.
|
||||
|
||||
Whether you're building Shoelace or building something _with_ Shoelace — have fun creating! 🥾
|
||||
|
|
|
@ -109,6 +109,7 @@
|
|||
"novalidate",
|
||||
"npmdir",
|
||||
"Numberish",
|
||||
"onscrollend",
|
||||
"outdir",
|
||||
"ParamagicDev",
|
||||
"peta",
|
||||
|
|
|
@ -205,6 +205,7 @@ export default {
|
|||
customElementJetBrainsPlugin({
|
||||
outdir: './dist',
|
||||
excludeCss: true,
|
||||
packageJson: false,
|
||||
referencesTemplate: (_, tag) => {
|
||||
return {
|
||||
name: 'Documentation',
|
||||
|
|
|
@ -235,7 +235,9 @@ code {
|
|||
kbd {
|
||||
background: var(--sl-color-neutral-100);
|
||||
border: solid 1px var(--sl-color-neutral-200);
|
||||
box-shadow: inset 0 1px 0 0 var(--sl-color-neutral-0), inset 0 -1px 0 0 var(--sl-color-neutral-200);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 var(--sl-color-neutral-0),
|
||||
inset 0 -1px 0 0 var(--sl-color-neutral-200);
|
||||
font-family: var(--sl-font-mono);
|
||||
font-size: 0.9125em;
|
||||
border-radius: var(--docs-border-radius);
|
||||
|
@ -511,7 +513,9 @@ pre .token.italic {
|
|||
right: 0;
|
||||
white-space: normal;
|
||||
color: var(--sl-color-neutral-800);
|
||||
transition: 150ms opacity, 150ms scale;
|
||||
transition:
|
||||
150ms opacity,
|
||||
150ms scale;
|
||||
}
|
||||
|
||||
.copy-code-button::part(button) {
|
||||
|
@ -659,6 +663,7 @@ pre:hover .copy-code-button,
|
|||
margin: -1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.content details summary span {
|
||||
|
@ -982,7 +987,9 @@ main {
|
|||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transition: 250ms scale ease, 250ms rotate ease;
|
||||
transition:
|
||||
250ms scale ease,
|
||||
250ms rotate ease;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
|
|
|
@ -803,7 +803,7 @@ const App = () => (
|
|||
|
||||
### Aspect Ratio
|
||||
|
||||
Use the `--aspect-ratio` custom property to customize the size of the carousel's viewport.
|
||||
Use the `--aspect-ratio` custom property to customize the size of the carousel's viewport from the default value of 16/9.
|
||||
|
||||
```html:preview
|
||||
<sl-carousel class="aspect-ratio" navigation pagination style="--aspect-ratio: 3/2;">
|
||||
|
@ -1246,7 +1246,7 @@ const App = () => {
|
|||
<img
|
||||
alt={`Thumbnail by ${i + 1}`}
|
||||
className={`thumbnails__image ${i === currentSlide ? 'active' : ''}`}
|
||||
onCLick={() => handleThumbnailClick(i)}
|
||||
onClick={() => handleThumbnailClick(i)}
|
||||
src={src}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -123,7 +123,9 @@ Details are designed to function independently, but you can simulate a group or
|
|||
|
||||
// Close all other details when one is shown
|
||||
container.addEventListener('sl-show', event => {
|
||||
if (event.target.localName === 'sl-details') {
|
||||
[...container.querySelectorAll('sl-details')].map(details => (details.open = event.target === details));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -249,7 +249,7 @@ const App = () => (
|
|||
|
||||
### Manual Trigger
|
||||
|
||||
Tooltips can be controller programmatically by setting the `trigger` attribute to `manual`. Use the `open` attribute to control when the tooltip is shown.
|
||||
Tooltips can be controlled programmatically by setting the `trigger` attribute to `manual`. Use the `open` attribute to control when the tooltip is shown.
|
||||
|
||||
```html:preview
|
||||
<sl-button style="margin-right: 4rem;">Toggle Manually</sl-button>
|
||||
|
|
|
@ -10,16 +10,41 @@ Angular [plays nice](https://custom-elements-everywhere.com/#angular) with custo
|
|||
|
||||
## Installation
|
||||
|
||||
### Download the npm package
|
||||
|
||||
To add Shoelace to your Angular app, install the package from npm.
|
||||
|
||||
```bash
|
||||
npm install @shoelace-style/shoelace
|
||||
```
|
||||
|
||||
Next, [include a theme](/getting-started/themes) and set the [base path](/getting-started/installation#setting-the-base-path) for icons and other assets. In this example, we'll import the light theme and use the CDN as a base path.
|
||||
### Update the Angular Configuration
|
||||
|
||||
Next, [include a theme](/getting-started/themes). In this example, we'll import the light theme.
|
||||
|
||||
Its also important to load the components by using a `<script>` tag into the index.html file. However, the Angular way to do it is by adding a script configurations into your angular.json file as follows:
|
||||
|
||||
```json
|
||||
"architect": {
|
||||
"build": {
|
||||
...
|
||||
"options": {
|
||||
...
|
||||
"styles": [
|
||||
"src/styles.scss",
|
||||
"@shoelace-style/shoelace/dist/themes/light.css"
|
||||
],
|
||||
"scripts": [
|
||||
"@shoelace-style/shoelace/dist/shoelace.js"
|
||||
]
|
||||
...
|
||||
```
|
||||
|
||||
### Setting up the base path
|
||||
|
||||
Next, set the [base path](/getting-started/installation#setting-the-base-path) for icons and other assets in the `main.ts`. In this example, we'll use the CDN as a base path.
|
||||
|
||||
```jsx
|
||||
import '@shoelace-style/shoelace/%NPMDIR%/themes/light.css';
|
||||
import { setBasePath } from '@shoelace-style/shoelace/%NPMDIR%/utilities/base-path';
|
||||
|
||||
setBasePath('https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@%VERSION%/%CDNDIR%/');
|
||||
|
|
|
@ -107,6 +107,19 @@ When binding complex data such as objects and arrays, use the `.prop` modifier t
|
|||
<sl-color-picker :swatches.prop="mySwatches" />
|
||||
```
|
||||
|
||||
### Two-way Binding
|
||||
|
||||
One caveat is there's currently [no support for v-model on custom elements](https://github.com/vuejs/vue/issues/7830), but you can still achieve two-way binding manually.
|
||||
|
||||
```html
|
||||
<!-- This doesn't work -->
|
||||
<sl-input v-model="name"></sl-input>
|
||||
<!-- This works, but it's a bit longer -->
|
||||
<sl-input :value="name" @input="name = $event.target.value"></sl-input>
|
||||
```
|
||||
|
||||
If that's too verbose for your liking, you can use a custom directive instead. [This utility](https://www.npmjs.com/package/@shoelace-style/vue-sl-model) adds a custom directive that will work just like `v-model` but for Shoelace components.
|
||||
|
||||
:::tip
|
||||
Are you using Shoelace with Vue? [Help us improve this page!](https://github.com/shoelace-style/shoelace/blob/next/docs/frameworks/vue.md)
|
||||
:::
|
||||
|
|
|
@ -214,7 +214,28 @@ If `settings.json` already exists, simply add the above line to the root of the
|
|||
|
||||
If you are using a [JetBrains IDE](https://www.jetbrains.com/) and you are installing Shoelace from NPM, the editor will automatically detect the `web-types.json` file from the package and you should immediately see component information in your editor.
|
||||
|
||||
If you are installing from the CDN, you can [download a local copy](https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace/dist/web-types.json) and add it to the root of your project.
|
||||
If you are installing from the CDN, you can [download a local copy](https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace/dist/web-types.json) and add it to the root of your project. Be sure to add a reference to the `web-types.json` file in your `package.json` in order for your editor to properly detect it.
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"web-types": "./web-types.json"
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
If you are using types from multiple projects, you can add an array of references.
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"web-types": [
|
||||
...,
|
||||
"./web-types.json"
|
||||
]
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Other Editors
|
||||
|
||||
|
|
|
@ -86,12 +86,10 @@ With Shoelace, you can:
|
|||
- Incrementally adopt components as needed (no need to ditch your framework)
|
||||
- Upgrade or switch frameworks without rebuilding foundational components
|
||||
|
||||
If your organization is looking to build a design system, [Shoelace will save you thousands of dollars](https://medium.com/eightshapes-llc/and-you-thought-buttons-were-easy-26eb5b5c1871).\* All the foundational components you need are right here, ready to be customized for your brand. And since it's built on web standards, browsers will continue to support it for many years to come.
|
||||
If your organization is looking to build a design system, [Shoelace will save you thousands of dollars](https://medium.com/eightshapes-llc/and-you-thought-buttons-were-easy-26eb5b5c1871). All the foundational components you need are right here, ready to be customized for your brand. And since it's built on web standards, browsers will continue to support it for many years to come.
|
||||
|
||||
Whether you use Shoelace as a starting point for your organization's design system or for a fun personal project, there's no limit to what you can do with it.
|
||||
|
||||
<small>\*Please consider giving back some of what you save by [supporting this project with a sponsorship](https://github.com/sponsors/claviska).</small>
|
||||
|
||||
## Browser Support
|
||||
|
||||
Shoelace is tested in the latest two versions of the following browsers.
|
||||
|
|
|
@ -12,12 +12,54 @@ Components with the <sl-badge variant="warning" pill>Experimental</sl-badge> bad
|
|||
|
||||
New versions of Shoelace are released as-needed and generally occur when a critical mass of changes have accumulated. At any time, you can see what's coming in the next release by visiting [next.shoelace.style](https://next.shoelace.style).
|
||||
|
||||
## Next
|
||||
## 2.12.0
|
||||
|
||||
- Added the Italian translation [#1727]
|
||||
- Added the ability to call `form.checkValidity()` and it will use Shoelace's custom `checkValidity()` handler. [#1708]
|
||||
- Fixed a bug where nested dialogs were not properly trapping focus. [#1711]
|
||||
- Fixed a bug with form controls removing the custom validity handlers from the form. [#1708]
|
||||
- Fixed a bug in form control components that used a `form` property, but not an attribute. [#1707]
|
||||
- Fixed a bug with bundled components using CDN builds not having translations on initial connect [#1696]
|
||||
- Fixed a bug where the `"sl-change"` event would always fire simultaneously with `"sl-input"` event in `<sl-color-picker>`. The `<sl-change>` event now only fires when a user stops dragging a slider or stops dragging on the color canvas. [#1689]
|
||||
- Updated the copy icon in the system library [#1702]
|
||||
|
||||
## 2.11.2
|
||||
|
||||
- Fixed a bug in `<sl-carousel>` component that caused an error to be thrown when rendered with Lit [#1684]
|
||||
|
||||
## 2.11.1
|
||||
|
||||
- Improved the experimental `<sl-carousel>` component [#1605]
|
||||
|
||||
## 2.11.0
|
||||
|
||||
- Added the Croatian translation [#1656]
|
||||
- Fixed a bug that caused the [[Escape]] key to stop propagating when tooltips are disabled [#1607]
|
||||
- Fixed a bug that made it impossible to style placeholders in `<sl-select>` [#1667]
|
||||
- Fixed a bug that caused `dist/react/index.js` to be blank [#1659]
|
||||
|
||||
## 2.10.0
|
||||
|
||||
- Added the Simplified Chinese translation [#1604]
|
||||
- Fixed a bug [in the localize dependency](https://github.com/shoelace-style/localize/issues/20) that caused underscores in language codes to throw a `RangeError`
|
||||
- Fixed a bug in the focus trapping utility used by modals that caused unexpected focus behavior. [#1583]
|
||||
- Fixed a bug in `<sl-copy-button>` that prevented exported tooltip parts from being styled [#1586]
|
||||
- Fixed a bug in `<sl-menu>` that caused it not to fire the `sl-select` event if you clicked an element inside of a `<sl-menu-item>` [#1599]
|
||||
- Fixed a bug that caused focus trap logic to hang the browser in certain circumstances [#1612]
|
||||
- Improved submenu selection by implementing the [safe triangle](https://www.smashingmagazine.com/2023/08/better-context-menus-safe-triangles/) method [#1550]
|
||||
- Updated `@shoelace-style/localize` to 3.1.0
|
||||
- Updated `@lib-labs/react` to stable `@lit/react`
|
||||
- Updated Bootstrap Icons to 1.11.1
|
||||
- Updated Lit to 3.0.0
|
||||
- Updated TypeScript to 5.2.2
|
||||
- Updated all other dependencies to latest versions
|
||||
|
||||
## 2.9.0
|
||||
|
||||
- Added the `modal` property to `<sl-dialog>` and `<sl-drawer>` to support third-party modals [#1571]
|
||||
- Fixed a bug in the autoloader causing it to register non-Shoelace elements [#1563]
|
||||
- Fixed a bug in `<sl-switch>` that resulted in improper spacing between the label and the required asterisk [#1540]
|
||||
- Fixed a bug in `<sl-icon>` that caused icons to not load when the default library used a sprite [#1572]
|
||||
- Fixed a bug in `<sl-icon>` that caused icons to not load when the default library used a sprite sheet [#1572]
|
||||
- Removed error when a missing popup anchor is provided [#1548]
|
||||
- Updated `@ctrl/tinycolor` to 4.0.1 [#1542]
|
||||
- Updated Bootstrap Icons to 1.11.0
|
||||
|
|
|
@ -36,6 +36,7 @@ I realize that one cannot reasonably enforce this any more than one can enforce
|
|||
The [issue tracker](https://github.com/shoelace-style/shoelace/issues) is for bug reports, feature requests, and pull requests.
|
||||
|
||||
- Please **do not** use the issue tracker for personal support requests. Use [the discussion forum](https://github.com/shoelace-style/shoelace/discussions/categories/help) instead.
|
||||
- Please **do not** use the issue tracker for feature requests. Use [the discussion forum](https://github.com/shoelace-style/shoelace/discussions/categories/ideas) instead.
|
||||
- Please **do not** derail, hijack, or troll issues. Keep the discussion on topic and be respectful of others.
|
||||
- Please **do not** post comments with "+1" or "👍". Use [reactions](https://github.blog/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) instead.
|
||||
- Please **do** use the issue tracker for feature requests, bug reports, and pull requests.
|
||||
|
@ -44,15 +45,13 @@ Issues that do not follow these guidelines are subject to closure. There simply
|
|||
|
||||
### Feature Requests
|
||||
|
||||
Feature requests can be added using the issue tracker.
|
||||
Feature requests can be added using [the discussion forum](https://github.com/shoelace-style/shoelace/discussions/categories/ideas).
|
||||
|
||||
- Please **do** search for an existing request before suggesting a new feature.
|
||||
- Please **do** use the "👍" reaction to vote for a feature.
|
||||
- Please **do** use the voting buttons to vote for a feature.
|
||||
- Please **do** share substantial use cases and perspective that support new features if they haven't already been mentioned.
|
||||
- Please **do not** bump, spam, or ping contributors to prioritize your own feature.
|
||||
|
||||
If you would like your feature prioritized, please consider [sponsoring the project](https://github.com/sponsors/claviska).
|
||||
|
||||
### Bug Reports
|
||||
|
||||
A bug is _a demonstrable problem_ caused by code in the library. Bug reports are an important contribution to the quality of the project. When submitting a bug report, there are a few steps you can take to make sure your issues gets attention quickly.
|
||||
|
@ -65,8 +64,6 @@ A bug is _a demonstrable problem_ caused by code in the library. Bug reports are
|
|||
|
||||
**A minimal test case is critical to a successful bug report.** It demonstrates that the bug exists in the library and not in surrounding code. Contributors should be able to understand the bug without studying your code, otherwise they'll probably move on to another bug.
|
||||
|
||||
If you would like your bug prioritized, please consider [sponsoring the project](https://github.com/sponsors/claviska).
|
||||
|
||||
### Pull Requests
|
||||
|
||||
To keep the project on track, please consider the following guidelines before submitting a PR.
|
||||
|
|
|
@ -88,7 +88,7 @@ module.exports = environment;
|
|||
The final step is to add the corresponding `pack_tags` to the page. You should have the following `tags` in the `<head>` section of `app/views/layouts/application.html.erb`.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- ... -->
|
||||
|
|
Plik diff jest za duży
Load Diff
99
package.json
99
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@shoelace-style/shoelace",
|
||||
"description": "A forward-thinking library of web components.",
|
||||
"version": "2.8.0",
|
||||
"version": "2.12.0",
|
||||
"homepage": "https://github.com/shoelace-style/shoelace",
|
||||
"author": "Cory LaViska",
|
||||
"license": "MIT",
|
||||
|
@ -25,8 +25,15 @@
|
|||
"./dist/react/*": "./dist/react/*",
|
||||
"./dist/translations/*": "./dist/translations/*"
|
||||
},
|
||||
"files": ["dist", "cdn"],
|
||||
"keywords": ["web components", "custom elements", "components"],
|
||||
"files": [
|
||||
"dist",
|
||||
"cdn"
|
||||
],
|
||||
"keywords": [
|
||||
"web components",
|
||||
"custom elements",
|
||||
"components"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/shoelace-style/shoelace.git"
|
||||
|
@ -43,11 +50,10 @@
|
|||
"build": "node scripts/build.js",
|
||||
"verify": "npm run prettier:check && npm run lint && npm run build && npm run test",
|
||||
"prepublishOnly": "npm run verify",
|
||||
"prettier": "prettier --write --loglevel warn .",
|
||||
"prettier:check": "prettier --check --loglevel warn .",
|
||||
"prettier": "prettier --write --log-level=warn .",
|
||||
"prettier:check": "prettier --check --log-level=warn .",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"lint:fix": "eslint src --max-warnings 0 --fix",
|
||||
"ts-check": "tsc --noEmit --project ./tsconfig.json",
|
||||
"create": "plop --plopfile scripts/plop/plopfile.js",
|
||||
"test": "web-test-runner --group default",
|
||||
"test:component": "web-test-runner -- --watch --group",
|
||||
|
@ -60,79 +66,82 @@
|
|||
"node": ">=14.17.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^4.0.1",
|
||||
"@floating-ui/dom": "^1.2.1",
|
||||
"@lit-labs/react": "^2.0.3",
|
||||
"@ctrl/tinycolor": "^4.0.2",
|
||||
"@floating-ui/dom": "^1.5.3",
|
||||
"@lit/react": "^1.0.0",
|
||||
"@shoelace-style/animations": "^1.1.0",
|
||||
"@shoelace-style/localize": "^3.1.1",
|
||||
"@shoelace-style/localize": "^3.1.2",
|
||||
"composed-offset-position": "^0.0.4",
|
||||
"lit": "^2.7.5",
|
||||
"lit": "^3.0.0",
|
||||
"qr-creator": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@11ty/eleventy": "^2.0.1",
|
||||
"@custom-elements-manifest/analyzer": "^0.8.3",
|
||||
"@open-wc/testing": "^3.1.7",
|
||||
"@types/mocha": "^10.0.1",
|
||||
"@types/react": "^18.0.26",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@web/dev-server-esbuild": "^0.3.3",
|
||||
"@web/test-runner": "^0.15.0",
|
||||
"@web/test-runner-commands": "^0.6.5",
|
||||
"@web/test-runner-playwright": "^0.9.0",
|
||||
"bootstrap-icons": "^1.11.0",
|
||||
"@custom-elements-manifest/analyzer": "^0.8.4",
|
||||
"@open-wc/testing": "^3.2.0",
|
||||
"@types/mocha": "^10.0.2",
|
||||
"@types/react": "^18.2.28",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
||||
"@typescript-eslint/parser": "^6.7.5",
|
||||
"@web/dev-server-esbuild": "^0.3.6",
|
||||
"@web/test-runner": "^0.18.0",
|
||||
"@web/test-runner-commands": "^0.9.0",
|
||||
"@web/test-runner-playwright": "^0.11.0",
|
||||
"bootstrap-icons": "^1.11.1",
|
||||
"browser-sync": "^2.29.3",
|
||||
"chalk": "^5.2.0",
|
||||
"chalk": "^5.3.0",
|
||||
"change-case": "^4.1.2",
|
||||
"command-line-args": "^5.2.1",
|
||||
"comment-parser": "^1.3.1",
|
||||
"comment-parser": "^1.4.0",
|
||||
"cspell": "^6.18.1",
|
||||
"custom-element-jet-brains-integration": "^1.1.0",
|
||||
"custom-element-vs-code-integration": "^1.1.0",
|
||||
"del": "^7.0.0",
|
||||
"custom-element-jet-brains-integration": "^1.4.0",
|
||||
"custom-element-vs-code-integration": "^1.2.1",
|
||||
"del": "^7.1.0",
|
||||
"download": "^8.0.0",
|
||||
"esbuild": "^0.18.2",
|
||||
"esbuild": "^0.19.4",
|
||||
"esbuild-plugin-replace": "^1.4.0",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint": "^8.51.0",
|
||||
"eslint-plugin-chai-expect": "^3.0.0",
|
||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-lit": "^1.8.3",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-lit": "^1.9.1",
|
||||
"eslint-plugin-lit-a11y": "^4.1.0",
|
||||
"eslint-plugin-markdown": "^3.0.0",
|
||||
"eslint-plugin-markdown": "^3.0.1",
|
||||
"eslint-plugin-sort-imports-es6-autofix": "^0.6.0",
|
||||
"eslint-plugin-wc": "^1.5.0",
|
||||
"eslint-plugin-wc": "^2.0.4",
|
||||
"front-matter": "^4.0.2",
|
||||
"get-port": "^7.0.0",
|
||||
"globby": "^13.1.3",
|
||||
"globby": "^13.2.2",
|
||||
"husky": "^8.0.3",
|
||||
"jsdom": "^22.1.0",
|
||||
"jsonata": "^2.0.1",
|
||||
"lint-staged": "^13.1.0",
|
||||
"jsonata": "^2.0.3",
|
||||
"lint-staged": "^14.0.1",
|
||||
"lunr": "^2.3.9",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-ins": "^3.0.1",
|
||||
"markdown-it-kbd": "^2.2.2",
|
||||
"markdown-it-mark": "^3.0.1",
|
||||
"markdown-it-replace-it": "^1.0.0",
|
||||
"npm-check-updates": "^16.6.2",
|
||||
"ora": "^6.3.1",
|
||||
"npm-check-updates": "^16.14.6",
|
||||
"ora": "^7.0.1",
|
||||
"pascal-case": "^3.1.2",
|
||||
"plop": "^3.1.1",
|
||||
"prettier": "^2.8.8",
|
||||
"plop": "^4.0.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"recursive-copy": "^2.0.14",
|
||||
"sinon": "^15.0.1",
|
||||
"sinon": "^16.1.0",
|
||||
"smartquotes": "^2.3.2",
|
||||
"source-map": "^0.7.4",
|
||||
"strip-css-comments": "^5.0.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.1.3",
|
||||
"user-agent-data-types": "^0.3.0"
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.2.2",
|
||||
"user-agent-data-types": "^0.3.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,js}": ["eslint --max-warnings 0 --cache --fix", "prettier --write"]
|
||||
"*.{ts,js}": [
|
||||
"eslint --max-warnings 0 --cache --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-env node */
|
||||
module.exports = {
|
||||
/** @type {import("prettier").Config} */
|
||||
const config = {
|
||||
arrowParens: 'avoid',
|
||||
bracketSpacing: true,
|
||||
htmlWhitespaceSensitivity: 'css',
|
||||
|
@ -16,3 +16,5 @@ module.exports = {
|
|||
trailingComma: 'none',
|
||||
useTabs: false
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -87,7 +87,7 @@ async function buildTheDocs(watch = false) {
|
|||
// Builds the source with esbuild.
|
||||
//
|
||||
async function buildTheSource() {
|
||||
const alwaysExternal = ['@lit-labs/react', 'react'];
|
||||
const alwaysExternal = ['@lit/react', 'react'];
|
||||
|
||||
const cdnConfig = {
|
||||
format: 'esm',
|
||||
|
@ -122,7 +122,7 @@ async function buildTheSource() {
|
|||
// We don't bundle certain dependencies in the unbundled build. This ensures we ship bare module specifiers,
|
||||
// allowing end users to better optimize when using a bundler. (Only packages that ship ESM can be external.)
|
||||
//
|
||||
// We never bundle React or @lit-labs/react though!
|
||||
// We never bundle React or @lit/react though!
|
||||
//
|
||||
external: alwaysExternal,
|
||||
splitting: true,
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import commandLineArgs from 'command-line-args';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { deleteSync } from 'del';
|
||||
import prettier from 'prettier';
|
||||
import prettierConfig from '../prettier.config.cjs';
|
||||
import { default as prettierConfig } from '../prettier.config.js';
|
||||
import { getAllComponents } from './shared.js';
|
||||
|
||||
const { outdir } = commandLineArgs({ name: 'outdir', type: String });
|
||||
|
@ -20,19 +19,18 @@ const metadata = JSON.parse(fs.readFileSync(path.join(outdir, 'custom-elements.j
|
|||
const components = getAllComponents(metadata);
|
||||
const index = [];
|
||||
|
||||
components.map(component => {
|
||||
for await (const component of components) {
|
||||
const tagWithoutPrefix = component.tagName.replace(/^sl-/, '');
|
||||
const componentDir = path.join(reactDir, tagWithoutPrefix);
|
||||
const componentFile = path.join(componentDir, 'index.ts');
|
||||
const importPath = component.path.replace(/\.js$/, '.component.js');
|
||||
const eventImports = (component.events || [])
|
||||
.map(event => `import type { ${event.eventName} } from '../../../src/events/events';`)
|
||||
.map(event => `import type { ${event.eventName} } from '../../events/events';`)
|
||||
.join('\n');
|
||||
const eventExports = (component.events || [])
|
||||
.map(event => `export type { ${event.eventName} } from '../../../src/events/events';`)
|
||||
.map(event => `export type { ${event.eventName} } from '../../events/events';`)
|
||||
.join('\n');
|
||||
const eventNameImport =
|
||||
(component.events || []).length > 0 ? `import { type EventName } from '@lit-labs/react';` : ``;
|
||||
const eventNameImport = (component.events || []).length > 0 ? `import { type EventName } from '@lit/react';` : ``;
|
||||
const events = (component.events || [])
|
||||
.map(event => `${event.reactName}: '${event.name}' as EventName<${event.eventName}>`)
|
||||
.join(',\n');
|
||||
|
@ -41,10 +39,10 @@ components.map(component => {
|
|||
|
||||
const jsDoc = component.jsDoc || '';
|
||||
|
||||
const source = prettier.format(
|
||||
const source = await prettier.format(
|
||||
`
|
||||
import * as React from 'react';
|
||||
import { createComponent } from '@lit-labs/react';
|
||||
import { createComponent } from '@lit/react';
|
||||
import Component from '../../${importPath}';
|
||||
|
||||
${eventNameImport}
|
||||
|
@ -75,7 +73,7 @@ components.map(component => {
|
|||
index.push(`export { default as ${component.name} } from './${tagWithoutPrefix}/index.js';`);
|
||||
|
||||
fs.writeFileSync(componentFile, source, 'utf8');
|
||||
});
|
||||
}
|
||||
|
||||
// Generate the index file
|
||||
fs.writeFileSync(path.join(reactDir, 'index.ts'), index.join('\n'), 'utf8');
|
||||
|
|
|
@ -24,7 +24,7 @@ filesToEmbed.forEach(file => {
|
|||
});
|
||||
|
||||
// Loop through each theme file, copying the .css and generating a .js version for Lit users
|
||||
files.forEach(file => {
|
||||
files.forEach(async file => {
|
||||
let source = fs.readFileSync(file, 'utf8');
|
||||
|
||||
// If the source has "/* _filename.css */" in it, replace it with the embedded styles
|
||||
|
@ -32,11 +32,11 @@ files.forEach(file => {
|
|||
source = source.replace(`/* ${key} */`, embeds[key]);
|
||||
});
|
||||
|
||||
const css = prettier.format(stripComments(source), {
|
||||
const css = await prettier.format(stripComments(source), {
|
||||
parser: 'css'
|
||||
});
|
||||
|
||||
let js = prettier.format(
|
||||
let js = await prettier.format(
|
||||
`
|
||||
import { css } from 'lit';
|
||||
|
||||
|
|
|
@ -210,10 +210,12 @@ describe('<sl-alert>', () => {
|
|||
};
|
||||
|
||||
it('deletes the toast stack after the last alert is done', async () => {
|
||||
const container = await fixture<HTMLElement>(html`<div>
|
||||
const container = await fixture<HTMLElement>(
|
||||
html`<div>
|
||||
<sl-alert data-testid="alert1" closable>alert 1</sl-alert>
|
||||
<sl-alert data-testid="alert2" closable>alert 2</sl-alert>
|
||||
</div>`);
|
||||
</div>`
|
||||
);
|
||||
|
||||
const alert1 = queryByTestId<SlAlert>(container, 'alert1');
|
||||
const alert2 = queryByTestId<SlAlert>(container, 'alert2');
|
||||
|
|
|
@ -23,6 +23,7 @@ export default css`
|
|||
font-weight: var(--sl-font-weight-normal);
|
||||
color: var(--sl-color-neutral-0);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ export default css`
|
|||
white-space: nowrap;
|
||||
padding: 0.35em 0.6em;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
|
|
|
@ -84,5 +84,6 @@ export default css`
|
|||
align-items: center;
|
||||
margin: 0 var(--sl-spacing-x-small);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -45,20 +45,9 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
|||
};
|
||||
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
form: input => {
|
||||
// Buttons support a form attribute that points to an arbitrary form, so if this attribute is set we need to query
|
||||
// the form from the same root using its id
|
||||
if (input.hasAttribute('form')) {
|
||||
const doc = input.getRootNode() as Document | ShadowRoot;
|
||||
const formId = input.getAttribute('form')!;
|
||||
return doc.getElementById(formId) as HTMLFormElement;
|
||||
}
|
||||
|
||||
// Fall back to the closest containing form
|
||||
return input.closest('form');
|
||||
},
|
||||
assumeInteractionOn: ['click']
|
||||
});
|
||||
|
||||
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
|
|
|
@ -22,11 +22,15 @@ export default css`
|
|||
font-weight: var(--sl-font-weight-semibold);
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
padding: 0;
|
||||
transition: var(--sl-transition-x-fast) background-color, var(--sl-transition-x-fast) color,
|
||||
var(--sl-transition-x-fast) border, var(--sl-transition-x-fast) box-shadow;
|
||||
transition:
|
||||
var(--sl-transition-x-fast) background-color,
|
||||
var(--sl-transition-x-fast) color,
|
||||
var(--sl-transition-x-fast) border,
|
||||
var(--sl-transition-x-fast) box-shadow;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
|
|
|
@ -99,25 +99,25 @@ describe('<sl-button>', () => {
|
|||
});
|
||||
|
||||
it('should render a link with rel="noreferrer noopener" when target is set and rel is not', async () => {
|
||||
const el = await fixture<SlButton>(
|
||||
html` <sl-button href="https://example.com/" target="_blank">Link</sl-button> `
|
||||
);
|
||||
const el = await fixture<SlButton>(html`
|
||||
<sl-button href="https://example.com/" target="_blank">Link</sl-button>
|
||||
`);
|
||||
const link = el.shadowRoot!.querySelector('a')!;
|
||||
expect(link?.getAttribute('rel')).to.equal('noreferrer noopener');
|
||||
});
|
||||
|
||||
it('should render a link with rel="" when a target is provided and rel is empty', async () => {
|
||||
const el = await fixture<SlButton>(
|
||||
html` <sl-button href="https://example.com/" target="_blank" rel="">Link</sl-button> `
|
||||
);
|
||||
const el = await fixture<SlButton>(html`
|
||||
<sl-button href="https://example.com/" target="_blank" rel="">Link</sl-button>
|
||||
`);
|
||||
const link = el.shadowRoot!.querySelector('a')!;
|
||||
expect(link?.getAttribute('rel')).to.equal('');
|
||||
});
|
||||
|
||||
it(`should render a link with a custom rel when a custom rel is provided`, async () => {
|
||||
const el = await fixture<SlButton>(
|
||||
html` <sl-button href="https://example.com/" target="_blank" rel="1">Link</sl-button> `
|
||||
);
|
||||
const el = await fixture<SlButton>(html`
|
||||
<sl-button href="https://example.com/" target="_blank" rel="1">Link</sl-button>
|
||||
`);
|
||||
const link = el.shadowRoot!.querySelector('a')!;
|
||||
expect(link?.getAttribute('rel')).to.equal('1');
|
||||
});
|
||||
|
|
|
@ -7,9 +7,9 @@ describe('<sl-card>', () => {
|
|||
|
||||
describe('when provided no parameters', () => {
|
||||
before(async () => {
|
||||
el = await fixture<SlCard>(
|
||||
html` <sl-card>This is just a basic card. No image, no header, and no footer. Just your content.</sl-card> `
|
||||
);
|
||||
el = await fixture<SlCard>(html`
|
||||
<sl-card>This is just a basic card. No image, no header, and no footer. Just your content.</sl-card>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', async () => {
|
||||
|
|
|
@ -17,10 +17,6 @@ import type { CSSResultGroup } from 'lit';
|
|||
export default class SlCarouselItem extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
static isCarouselItem(node: Node) {
|
||||
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute('role', 'group');
|
||||
|
|
|
@ -19,8 +19,8 @@ export default css`
|
|||
}
|
||||
|
||||
::slotted(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -10,7 +10,7 @@ describe('<sl-carousel-item>', () => {
|
|||
|
||||
it('should pass accessibility tests', async () => {
|
||||
// Arrange
|
||||
const el = await fixture(html` <div role="list"><sl-carousel-item></sl-carousel-item></div> `);
|
||||
const el = await fixture(html` <sl-carousel-item></sl-carousel-item> `);
|
||||
|
||||
// Assert
|
||||
await expect(el).to.be.accessible();
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import '../../internal/scrollend-polyfill.js';
|
||||
|
||||
import { AutoplayController } from './autoplay-controller.js';
|
||||
import { clamp } from '../../internal/math.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
@ -10,10 +12,10 @@ import { range } from 'lit/directives/range.js';
|
|||
import { ScrollController } from './scroll-controller.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlCarouselItem from '../carousel-item/carousel-item.component.js';
|
||||
import SlIcon from '../icon/icon.component.js';
|
||||
import styles from './carousel.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type { CSSResultGroup, PropertyValueMap } from 'lit';
|
||||
import type SlCarouselItem from '../carousel-item/carousel-item.component.js';
|
||||
|
||||
/**
|
||||
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
|
||||
|
@ -40,7 +42,7 @@ import type { CSSResultGroup } from 'lit';
|
|||
* @csspart navigation-button--next - Applied to the next button.
|
||||
*
|
||||
* @cssproperty --slide-gap - The space between each slide.
|
||||
* @cssproperty --aspect-ratio - The aspect ratio of each slide.
|
||||
* @cssproperty [--aspect-ratio=16/9] - The aspect ratio of each slide.
|
||||
* @cssproperty --scroll-hint - The amount of padding to apply to the scroll area, allowing adjacent slides to become
|
||||
* partially visible as a scroll hint.
|
||||
*/
|
||||
|
@ -68,7 +70,7 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
|
||||
/**
|
||||
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
|
||||
* greater than one.
|
||||
* greater than one. It can't be higher than `slides-per-page`.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
|
||||
|
||||
|
@ -78,7 +80,6 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
/** When set, it is possible to scroll through the slides by dragging them with the mouse. */
|
||||
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
|
||||
|
||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||
@query('.carousel__slides') scrollContainer: HTMLElement;
|
||||
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
||||
|
||||
|
@ -87,7 +88,6 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
|
||||
private autoplayController = new AutoplayController(this, () => this.next());
|
||||
private scrollController = new ScrollController(this);
|
||||
private readonly slides = this.getElementsByTagName('sl-carousel-item');
|
||||
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
|
||||
// A map containing the state of all the slides
|
||||
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
|
||||
|
@ -133,19 +133,45 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
protected firstUpdated(): void {
|
||||
this.initializeSlides();
|
||||
this.mutationObserver = new MutationObserver(this.handleSlotChange);
|
||||
this.mutationObserver.observe(this, { childList: true, subtree: false });
|
||||
this.mutationObserver.observe(this, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValueMap<SlCarousel> | Map<PropertyKey, unknown>): void {
|
||||
// Ensure the slidesPerMove is never higher than the slidesPerPage
|
||||
if (changedProperties.has('slidesPerMove') || changedProperties.has('slidesPerPage')) {
|
||||
this.slidesPerMove = Math.min(this.slidesPerMove, this.slidesPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
private getPageCount() {
|
||||
return Math.ceil(this.getSlides().length / this.slidesPerPage);
|
||||
const slidesCount = this.getSlides().length;
|
||||
const { slidesPerPage, slidesPerMove, loop } = this;
|
||||
|
||||
const pages = loop ? slidesCount / slidesPerMove : (slidesCount - slidesPerPage) / slidesPerMove + 1;
|
||||
|
||||
return Math.ceil(pages);
|
||||
}
|
||||
|
||||
private getCurrentPage() {
|
||||
return Math.ceil(this.activeSlide / this.slidesPerPage);
|
||||
return Math.ceil(this.activeSlide / this.slidesPerMove);
|
||||
}
|
||||
|
||||
private canScrollNext(): boolean {
|
||||
return this.loop || this.getCurrentPage() < this.getPageCount() - 1;
|
||||
}
|
||||
|
||||
private canScrollPrev(): boolean {
|
||||
return this.loop || this.getCurrentPage() > 0;
|
||||
}
|
||||
|
||||
/** @internal Gets all carousel items. */
|
||||
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
||||
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
|
||||
return [...this.children].filter(
|
||||
(el: HTMLElement) => this.isCarouselItem(el) && (!excludeClones || !el.hasAttribute('data-clone'))
|
||||
) as SlCarouselItem[];
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
|
@ -201,20 +227,22 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
|
||||
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
|
||||
this.goToSlide(clonePosition, 'auto');
|
||||
|
||||
return;
|
||||
} else if (firstIntersecting) {
|
||||
// Update the current index based on the first visible slide
|
||||
const slideIndex = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
||||
// Set the index to the first "snappable" slide
|
||||
this.activeSlide = Math.ceil(slideIndex / this.slidesPerMove) * this.slidesPerMove;
|
||||
}
|
||||
}
|
||||
|
||||
// Activate the first intersecting slide
|
||||
if (firstIntersecting) {
|
||||
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
||||
}
|
||||
private isCarouselItem(node: Node): node is SlCarouselItem {
|
||||
return node instanceof Element && node.tagName.toLowerCase() === 'sl-carousel-item';
|
||||
}
|
||||
|
||||
private handleSlotChange = (mutations: MutationRecord[]) => {
|
||||
const needsInitialization = mutations.some(mutation =>
|
||||
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
||||
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
|
||||
(el: HTMLElement) => this.isCarouselItem(el) && !el.hasAttribute('data-clone')
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -222,13 +250,13 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
if (needsInitialization) {
|
||||
this.initializeSlides();
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
@watch('loop', { waitUntilFirstUpdate: true })
|
||||
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
||||
initializeSlides() {
|
||||
const slides = this.getSlides();
|
||||
const intersectionObserver = this.intersectionObserver;
|
||||
|
||||
this.intersectionObserverEntries.clear();
|
||||
|
@ -246,8 +274,24 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
}
|
||||
});
|
||||
|
||||
this.updateSlidesSnap();
|
||||
|
||||
if (this.loop) {
|
||||
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
|
||||
this.createClones();
|
||||
}
|
||||
|
||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||
intersectionObserver.observe(slide);
|
||||
});
|
||||
|
||||
// Because the DOM may be changed, restore the scroll position to the active slide
|
||||
this.goToSlide(this.activeSlide, 'auto');
|
||||
}
|
||||
|
||||
private createClones() {
|
||||
const slides = this.getSlides();
|
||||
|
||||
const slidesPerPage = this.slidesPerPage;
|
||||
const lastSlides = slides.slice(-slidesPerPage);
|
||||
const firstSlides = slides.slice(0, slidesPerPage);
|
||||
|
@ -265,14 +309,6 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
});
|
||||
}
|
||||
|
||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||
intersectionObserver.observe(slide);
|
||||
});
|
||||
|
||||
// Because the DOM may be changed, restore the scroll position to the active slide
|
||||
this.goToSlide(this.activeSlide, 'auto');
|
||||
}
|
||||
|
||||
@watch('activeSlide')
|
||||
handelSlideChange() {
|
||||
const slides = this.getSlides();
|
||||
|
@ -292,12 +328,12 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
}
|
||||
|
||||
@watch('slidesPerMove')
|
||||
handleSlidesPerMoveChange() {
|
||||
const slides = this.getSlides({ excludeClones: false });
|
||||
updateSlidesSnap() {
|
||||
const slides = this.getSlides();
|
||||
|
||||
const slidesPerMove = this.slidesPerMove;
|
||||
slides.forEach((slide, i) => {
|
||||
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
|
||||
const shouldSnap = (i + slidesPerMove) % slidesPerMove === 0;
|
||||
if (shouldSnap) {
|
||||
slide.style.removeProperty('scroll-snap-align');
|
||||
} else {
|
||||
|
@ -325,15 +361,7 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
* @param behavior - The behavior used for scrolling.
|
||||
*/
|
||||
previous(behavior: ScrollBehavior = 'smooth') {
|
||||
let previousIndex = this.activeSlide || this.activeSlide - this.slidesPerMove;
|
||||
let canSnap = false;
|
||||
|
||||
while (!canSnap && previousIndex > 0) {
|
||||
previousIndex -= 1;
|
||||
canSnap = Math.abs(previousIndex - this.slidesPerMove) % this.slidesPerMove === 0;
|
||||
}
|
||||
|
||||
this.goToSlide(previousIndex, behavior);
|
||||
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -357,8 +385,13 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
const slides = this.getSlides();
|
||||
const slidesWithClones = this.getSlides({ excludeClones: false });
|
||||
|
||||
// No need to do anything in case there are no items in the carousel
|
||||
if (!slides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sets the next index without taking into account clones, if any.
|
||||
const newActiveSlide = (index + slides.length) % slides.length;
|
||||
const newActiveSlide = loop ? (index + slides.length) % slides.length : clamp(index, 0, slides.length - 1);
|
||||
this.activeSlide = newActiveSlide;
|
||||
|
||||
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
||||
|
@ -377,11 +410,11 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { scrollController, slidesPerPage } = this;
|
||||
const { scrollController, slidesPerMove } = this;
|
||||
const pagesCount = this.getPageCount();
|
||||
const currentPage = this.getCurrentPage();
|
||||
const prevEnabled = this.loop || currentPage > 0;
|
||||
const nextEnabled = this.loop || currentPage < pagesCount - 1;
|
||||
const prevEnabled = this.canScrollPrev();
|
||||
const nextEnabled = this.canScrollNext();
|
||||
const isLtr = this.localize.dir() === 'ltr';
|
||||
|
||||
return html`
|
||||
|
@ -459,7 +492,7 @@ export default class SlCarousel extends ShoelaceElement {
|
|||
aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
||||
tabindex=${isActive ? '0' : '-1'}
|
||||
@click=${() => this.goToSlide(index * slidesPerPage)}
|
||||
@click=${() => this.goToSlide(index * slidesPerMove)}
|
||||
@keydown=${this.handleKeyDown}
|
||||
></button>
|
||||
`;
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { clickOnElement } from '../../internal/test.js';
|
||||
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { map } from 'lit/directives/map.js';
|
||||
import { range } from 'lit/directives/range.js';
|
||||
import sinon from 'sinon';
|
||||
import type SlCarousel from './carousel.js';
|
||||
|
||||
|
@ -223,6 +225,36 @@ describe('<sl-carousel>', () => {
|
|||
// Assert
|
||||
expect(el.scrollContainer.style.getPropertyValue('--slides-per-page').trim()).to.be.equal('2');
|
||||
});
|
||||
|
||||
[
|
||||
[7, 2, 1, false, 6],
|
||||
[5, 3, 3, false, 2],
|
||||
[10, 2, 2, false, 5],
|
||||
[7, 2, 1, true, 7],
|
||||
[5, 3, 3, true, 2],
|
||||
[10, 2, 2, true, 5]
|
||||
].forEach(([slides, slidesPerPage, slidesPerMove, loop, expected]: [number, number, number, boolean, number]) => {
|
||||
it(`should display ${expected} pages for ${slides} slides grouped by ${slidesPerPage} and scrolled by ${slidesPerMove}${
|
||||
loop ? ' (loop)' : ''
|
||||
}`, async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel
|
||||
pagination
|
||||
navigation
|
||||
slides-per-page="${slidesPerPage}"
|
||||
slides-per-move="${slidesPerMove}"
|
||||
?loop=${loop}
|
||||
>
|
||||
${map(range(slides), i => html`<sl-carousel-item>${i}</sl-carousel-item>`)}
|
||||
</sl-carousel>
|
||||
`);
|
||||
|
||||
// Assert
|
||||
const paginationItems = el.shadowRoot!.querySelectorAll('.carousel__pagination-item');
|
||||
expect(paginationItems.length).to.equal(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `slides-per-move` attribute is provided', () => {
|
||||
|
@ -230,7 +262,7 @@ describe('<sl-carousel>', () => {
|
|||
// Arrange
|
||||
const expectedSnapGranularity = 2;
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel slides-per-move="${expectedSnapGranularity}">
|
||||
<sl-carousel slides-per-page="${expectedSnapGranularity}" slides-per-move="${expectedSnapGranularity}">
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
|
@ -252,6 +284,89 @@ describe('<sl-carousel>', () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should be possible to move by the given number of slides at a time', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel navigation slides-per-move="2" slides-per-page="2">
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item class="expected">Node 3</sl-carousel-item>
|
||||
<sl-carousel-item class="expected">Node 4</sl-carousel-item>
|
||||
<sl-carousel-item>Node 5</sl-carousel-item>
|
||||
<sl-carousel-item>Node 6</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
const expectedSlides = el.querySelectorAll('.expected')!;
|
||||
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||
|
||||
// Act
|
||||
await clickOnElement(nextButton);
|
||||
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
for (const expectedSlide of expectedSlides) {
|
||||
expect(expectedSlide).to.have.class('--in-view');
|
||||
expect(expectedSlide).to.be.visible;
|
||||
}
|
||||
});
|
||||
|
||||
it('should be possible to move by a number that is less than the displayed number', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel navigation slides-per-move="1" slides-per-page="2">
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
<sl-carousel-item>Node 4</sl-carousel-item>
|
||||
<sl-carousel-item class="expected">Node 5</sl-carousel-item>
|
||||
<sl-carousel-item class="expected">Node 6</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
const expectedSlides = el.querySelectorAll('.expected')!;
|
||||
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||
|
||||
// Act
|
||||
await clickOnElement(nextButton);
|
||||
await clickOnElement(nextButton);
|
||||
await clickOnElement(nextButton);
|
||||
await clickOnElement(nextButton);
|
||||
await clickOnElement(nextButton);
|
||||
await clickOnElement(nextButton);
|
||||
|
||||
await oneEvent(el.scrollContainer, 'scrollend');
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
for (const expectedSlide of expectedSlides) {
|
||||
expect(expectedSlide).to.have.class('--in-view');
|
||||
expect(expectedSlide).to.be.visible;
|
||||
}
|
||||
});
|
||||
|
||||
it('should not be possible to move by a number that is greater than the displayed number', async () => {
|
||||
// Arrange
|
||||
const expectedSlidesPerMove = 2;
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel slides-per-page="${expectedSlidesPerMove}">
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
<sl-carousel-item>Node 4</sl-carousel-item>
|
||||
<sl-carousel-item>Node 5</sl-carousel-item>
|
||||
<sl-carousel-item>Node 6</sl-carousel-item>
|
||||
</sl-carousel>
|
||||
`);
|
||||
|
||||
// Act
|
||||
el.slidesPerMove = 3;
|
||||
await el.updateComplete;
|
||||
|
||||
// Assert
|
||||
expect(el.slidesPerMove).to.be.equal(expectedSlidesPerMove);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `orientation` attribute is provided', () => {
|
||||
|
@ -465,7 +580,7 @@ describe('<sl-carousel>', () => {
|
|||
it('should scroll the carousel to the next slide', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel slides-per-move="2">
|
||||
<sl-carousel slides-per-page="2" slides-per-move="2">
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
|
@ -485,7 +600,7 @@ describe('<sl-carousel>', () => {
|
|||
it('should scroll the carousel to the previous slide', async () => {
|
||||
// Arrange
|
||||
const el = await fixture<SlCarousel>(html`
|
||||
<sl-carousel slides-per-move="2">
|
||||
<sl-carousel slides-per-page="2" slides-per-move="2">
|
||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { debounce } from '../../internal/debounce.js';
|
||||
import { prefersReducedMotion } from '../../internal/animate.js';
|
||||
import { waitForEvent } from '../../internal/event.js';
|
||||
import type { ReactiveController, ReactiveElement } from 'lit';
|
||||
|
@ -12,7 +11,6 @@ interface ScrollHost extends ReactiveElement {
|
|||
*/
|
||||
export class ScrollController<T extends ScrollHost> implements ReactiveController {
|
||||
private host: T;
|
||||
private pointers = new Set();
|
||||
|
||||
dragging = false;
|
||||
scrolling = false;
|
||||
|
@ -30,11 +28,10 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
|||
const scrollContainer = host.scrollContainer;
|
||||
|
||||
scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true });
|
||||
scrollContainer.addEventListener('scrollend', this.handleScrollEnd, true);
|
||||
scrollContainer.addEventListener('pointerdown', this.handlePointerDown);
|
||||
scrollContainer.addEventListener('pointerup', this.handlePointerUp);
|
||||
scrollContainer.addEventListener('pointercancel', this.handlePointerUp);
|
||||
scrollContainer.addEventListener('touchstart', this.handleTouchStart, { passive: true });
|
||||
scrollContainer.addEventListener('touchend', this.handleTouchEnd);
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
|
@ -42,11 +39,10 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
|||
const scrollContainer = host.scrollContainer;
|
||||
|
||||
scrollContainer.removeEventListener('scroll', this.handleScroll);
|
||||
scrollContainer.removeEventListener('scrollend', this.handleScrollEnd, true);
|
||||
scrollContainer.removeEventListener('pointerdown', this.handlePointerDown);
|
||||
scrollContainer.removeEventListener('pointerup', this.handlePointerUp);
|
||||
scrollContainer.removeEventListener('pointercancel', this.handlePointerUp);
|
||||
scrollContainer.removeEventListener('touchstart', this.handleTouchStart);
|
||||
scrollContainer.removeEventListener('touchend', this.handleTouchEnd);
|
||||
}
|
||||
|
||||
handleScroll = () => {
|
||||
|
@ -54,35 +50,22 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
|||
this.scrolling = true;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
this.handleScrollEnd();
|
||||
};
|
||||
|
||||
@debounce(100)
|
||||
handleScrollEnd() {
|
||||
if (!this.pointers.size) {
|
||||
// If no pointer is active in the scroll area then the scroll has ended
|
||||
handleScrollEnd = () => {
|
||||
if (this.scrolling && !this.dragging) {
|
||||
this.scrolling = false;
|
||||
this.host.scrollContainer.dispatchEvent(
|
||||
new CustomEvent('scrollend', {
|
||||
bubbles: false,
|
||||
cancelable: false
|
||||
})
|
||||
);
|
||||
this.host.requestUpdate();
|
||||
} else {
|
||||
// otherwise let's wait a bit more
|
||||
this.handleScrollEnd();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handlePointerDown = (event: PointerEvent) => {
|
||||
// Do not handle drag for touch interactions as scroll is natively supported
|
||||
if (event.pointerType === 'touch') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pointers.add(event.pointerId);
|
||||
|
||||
const canDrag = this.mouseDragging && !this.dragging && event.button === 0;
|
||||
const canDrag = this.mouseDragging && event.button === 0;
|
||||
if (canDrag) {
|
||||
event.preventDefault();
|
||||
|
||||
|
@ -105,24 +88,9 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
|||
};
|
||||
|
||||
handlePointerUp = (event: PointerEvent) => {
|
||||
this.pointers.delete(event.pointerId);
|
||||
this.host.scrollContainer.releasePointerCapture(event.pointerId);
|
||||
|
||||
if (this.pointers.size === 0) {
|
||||
this.handleDragEnd();
|
||||
}
|
||||
};
|
||||
|
||||
handleTouchEnd = (event: TouchEvent) => {
|
||||
for (const touch of event.changedTouches) {
|
||||
this.pointers.delete(touch.identifier);
|
||||
}
|
||||
};
|
||||
|
||||
handleTouchStart = (event: TouchEvent) => {
|
||||
for (const touch of event.touches) {
|
||||
this.pointers.add(touch.identifier);
|
||||
}
|
||||
};
|
||||
|
||||
handleDragStart() {
|
||||
|
@ -140,12 +108,11 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
|||
});
|
||||
}
|
||||
|
||||
async handleDragEnd() {
|
||||
handleDragEnd() {
|
||||
const host = this.host;
|
||||
const scrollContainer = host.scrollContainer;
|
||||
|
||||
scrollContainer.removeEventListener('pointermove', this.handlePointerMove);
|
||||
this.dragging = false;
|
||||
|
||||
const startLeft = scrollContainer.scrollLeft;
|
||||
const startTop = scrollContainer.scrollTop;
|
||||
|
@ -158,12 +125,16 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
|||
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' });
|
||||
scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||
|
||||
if (this.scrolling) {
|
||||
// Wait for scroll to be applied
|
||||
requestAnimationFrame(async () => {
|
||||
if (startLeft !== finalLeft || startTop !== finalTop) {
|
||||
await waitForEvent(scrollContainer, 'scrollend');
|
||||
}
|
||||
|
||||
scrollContainer.style.removeProperty('scroll-snap-type');
|
||||
|
||||
this.dragging = false;
|
||||
host.requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,8 +46,11 @@ export default css`
|
|||
border-radius: 2px;
|
||||
background-color: var(--sl-input-background-color);
|
||||
color: var(--sl-color-neutral-0);
|
||||
transition: var(--sl-transition-fast) border-color, var(--sl-transition-fast) background-color,
|
||||
var(--sl-transition-fast) color, var(--sl-transition-fast) box-shadow;
|
||||
transition:
|
||||
var(--sl-transition-fast) border-color,
|
||||
var(--sl-transition-fast) background-color,
|
||||
var(--sl-transition-fast) color,
|
||||
var(--sl-transition-fast) box-shadow;
|
||||
}
|
||||
|
||||
.checkbox__input {
|
||||
|
@ -110,6 +113,7 @@ export default css`
|
|||
line-height: var(--toggle-size);
|
||||
margin-inline-start: 0.5em;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
:host([required]) .checkbox__label::after {
|
||||
|
|
|
@ -243,7 +243,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__alpha')!;
|
||||
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
|
||||
const { width } = container.getBoundingClientRect();
|
||||
let oldValue = this.value;
|
||||
let initialValue = this.value;
|
||||
let currentValue = this.value;
|
||||
|
||||
handle.focus();
|
||||
event.preventDefault();
|
||||
|
@ -253,12 +254,17 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
this.alpha = clamp((x / width) * 100, 0, 100);
|
||||
this.syncValues();
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
oldValue = this.value;
|
||||
this.emit('sl-change');
|
||||
if (this.value !== currentValue) {
|
||||
currentValue = this.value;
|
||||
this.emit('sl-input');
|
||||
}
|
||||
},
|
||||
onStop: () => {
|
||||
if (this.value !== initialValue) {
|
||||
initialValue = this.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
@ -267,7 +273,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__hue')!;
|
||||
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
|
||||
const { width } = container.getBoundingClientRect();
|
||||
let oldValue = this.value;
|
||||
let initialValue = this.value;
|
||||
let currentValue = this.value;
|
||||
|
||||
handle.focus();
|
||||
event.preventDefault();
|
||||
|
@ -277,12 +284,17 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
this.hue = clamp((x / width) * 360, 0, 360);
|
||||
this.syncValues();
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
oldValue = this.value;
|
||||
this.emit('sl-change');
|
||||
if (this.value !== currentValue) {
|
||||
currentValue = this.value;
|
||||
this.emit('sl-input');
|
||||
}
|
||||
},
|
||||
onStop: () => {
|
||||
if (this.value !== initialValue) {
|
||||
initialValue = this.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
@ -291,7 +303,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
const grid = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__grid')!;
|
||||
const handle = grid.querySelector<HTMLElement>('.color-picker__grid-handle')!;
|
||||
const { width, height } = grid.getBoundingClientRect();
|
||||
let oldValue = this.value;
|
||||
let initialValue = this.value;
|
||||
let currentValue = this.value;
|
||||
|
||||
handle.focus();
|
||||
event.preventDefault();
|
||||
|
@ -304,13 +317,18 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
|||
this.brightness = clamp(100 - (y / height) * 100, 0, 100);
|
||||
this.syncValues();
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
oldValue = this.value;
|
||||
this.emit('sl-change');
|
||||
if (this.value !== currentValue) {
|
||||
currentValue = this.value;
|
||||
this.emit('sl-input');
|
||||
}
|
||||
},
|
||||
onStop: () => (this.isDraggingGridHandle = false),
|
||||
onStop: () => {
|
||||
this.isDraggingGridHandle = false;
|
||||
if (this.value !== initialValue) {
|
||||
initialValue = this.value;
|
||||
this.emit('sl-change');
|
||||
}
|
||||
},
|
||||
initialEvent: event
|
||||
});
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ export default css`
|
|||
background-color: var(--sl-panel-background-color);
|
||||
border-radius: var(--sl-border-radius-medium);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.color-picker--inline {
|
||||
|
@ -245,7 +246,11 @@ export default css`
|
|||
linear-gradient(45deg, transparent 75%, var(--sl-color-neutral-300) 75%),
|
||||
linear-gradient(45deg, var(--sl-color-neutral-300) 25%, transparent 25%);
|
||||
background-size: 10px 10px;
|
||||
background-position: 0 0, 0 0, -5px -5px, 5px 5px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 0,
|
||||
-5px -5px,
|
||||
5px 5px;
|
||||
}
|
||||
|
||||
.color-picker--disabled {
|
||||
|
@ -311,7 +316,9 @@ export default css`
|
|||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background-color: currentColor;
|
||||
box-shadow: inset 0 0 0 2px var(--sl-input-border-color), inset 0 0 0 4px var(--sl-color-neutral-0);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--sl-input-border-color),
|
||||
inset 0 0 0 4px var(--sl-color-neutral-0);
|
||||
}
|
||||
|
||||
.color-dropdown__trigger--empty:before {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { clickOnElement } from '../../internal/test.js';
|
||||
import { clickOnElement, dragElement } from '../../internal/test.js';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { serialize } from '../../utilities/form.js';
|
||||
|
@ -31,11 +31,22 @@ describe('<sl-color-picker>', () => {
|
|||
|
||||
await clickOnElement(trigger); // open the dropdown
|
||||
await aTimeout(200); // wait for the dropdown to open
|
||||
await clickOnElement(grid); // click on the grid
|
||||
|
||||
// Simulate a drag event. "sl-change" should not fire until we stop dragging.
|
||||
await dragElement(grid, 2, 0, {
|
||||
afterMouseDown: () => {
|
||||
expect(changeHandler).to.have.not.been.called;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
},
|
||||
afterMouseMove: () => {
|
||||
expect(inputHandler).to.have.been.calledTwice;
|
||||
}
|
||||
});
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
expect(changeHandler).to.have.been.calledOnce;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
expect(inputHandler).to.have.been.calledTwice;
|
||||
});
|
||||
|
||||
it('should emit sl-change and sl-input when the hue slider is moved', async () => {
|
||||
|
@ -50,10 +61,22 @@ describe('<sl-color-picker>', () => {
|
|||
|
||||
await clickOnElement(trigger); // open the dropdown
|
||||
await aTimeout(200); // wait for the dropdown to open
|
||||
await clickOnElement(slider); // click on the hue slider
|
||||
// Simulate a drag event. "sl-change" should not fire until we stop dragging.
|
||||
await dragElement(slider, 20, 0, {
|
||||
afterMouseDown: () => {
|
||||
expect(changeHandler).to.have.not.been.called;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
},
|
||||
afterMouseMove: () => {
|
||||
// It's not twice because you can't change the hue of white!
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
}
|
||||
});
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
expect(changeHandler).to.have.been.calledOnce;
|
||||
// It's not twice because you can't change the hue of white!
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
|
@ -69,11 +92,22 @@ describe('<sl-color-picker>', () => {
|
|||
|
||||
await clickOnElement(trigger); // open the dropdown
|
||||
await aTimeout(200); // wait for the dropdown to open
|
||||
await clickOnElement(slider); // click on the opacity slider
|
||||
|
||||
// Simulate a drag event. "sl-change" should not fire until we stop dragging.
|
||||
await dragElement(slider, 2, 0, {
|
||||
afterMouseDown: () => {
|
||||
expect(changeHandler).to.have.not.been.called;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
},
|
||||
afterMouseMove: () => {
|
||||
expect(inputHandler).to.have.been.calledTwice;
|
||||
}
|
||||
});
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
expect(changeHandler).to.have.been.calledOnce;
|
||||
expect(inputHandler).to.have.been.calledOnce;
|
||||
expect(inputHandler).to.have.been.calledTwice;
|
||||
});
|
||||
|
||||
it('should emit sl-change and sl-input when toggling the format', async () => {
|
||||
|
@ -97,9 +131,9 @@ describe('<sl-color-picker>', () => {
|
|||
});
|
||||
|
||||
it('should render the correct swatches when passing a string of color values', async () => {
|
||||
const el = await fixture<SlColorPicker>(
|
||||
html` <sl-color-picker swatches="red; #008000; rgb(0,0,255);"></sl-color-picker> `
|
||||
);
|
||||
const el = await fixture<SlColorPicker>(html`
|
||||
<sl-color-picker swatches="red; #008000; rgb(0,0,255);"></sl-color-picker>
|
||||
`);
|
||||
const swatches = [...el.shadowRoot!.querySelectorAll('[part~="swatch"] > div')];
|
||||
|
||||
expect(swatches.length).to.equal(3);
|
||||
|
@ -326,7 +360,7 @@ describe('<sl-color-picker>', () => {
|
|||
expect(previewColor).to.equal('#ff000050');
|
||||
});
|
||||
|
||||
it('should emit sl-focus when rendered as a dropdown and focused', async () => {
|
||||
it.skip('should emit sl-focus when rendered as a dropdown and focused', async () => {
|
||||
const el = await fixture<SlColorPicker>(html`
|
||||
<div>
|
||||
<sl-color-picker></sl-color-picker>
|
||||
|
|
|
@ -206,9 +206,9 @@ export default class SlCopyButton extends ShoelaceElement {
|
|||
?disabled=${this.disabled}
|
||||
?hoist=${this.hoist}
|
||||
exportparts="
|
||||
base:tooltip__base
|
||||
base__popup:tooltip__base__popup
|
||||
base__arrow:tooltip__base__arrow
|
||||
base:tooltip__base,
|
||||
base__popup:tooltip__base__popup,
|
||||
base__arrow:tooltip__base__arrow,
|
||||
body:tooltip__body
|
||||
"
|
||||
>
|
||||
|
|
|
@ -25,6 +25,7 @@ export default css`
|
|||
border-radius: inherit;
|
||||
padding: var(--sl-spacing-medium);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
|
@ -17,9 +17,9 @@ describe('<sl-dialog>', () => {
|
|||
});
|
||||
|
||||
it('should not be visible without the open attribute', async () => {
|
||||
const el = await fixture<SlDialog>(
|
||||
html` <sl-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog> `
|
||||
);
|
||||
const el = await fixture<SlDialog>(html`
|
||||
<sl-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
|
||||
`);
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
|
||||
expect(base.hidden).to.be.true;
|
||||
|
|
|
@ -16,9 +16,9 @@ describe('<sl-drawer>', () => {
|
|||
});
|
||||
|
||||
it('should not be visible without the open attribute', async () => {
|
||||
const el = await fixture<SlDrawer>(
|
||||
html` <sl-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer> `
|
||||
);
|
||||
const el = await fixture<SlDrawer>(html`
|
||||
<sl-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
|
||||
`);
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
|
||||
expect(base.hidden).to.be.true;
|
||||
|
|
|
@ -52,11 +52,9 @@ describe('<sl-format-date>', () => {
|
|||
];
|
||||
results.forEach(setup => {
|
||||
it(`date has correct language format: ${setup.lang}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" lang="${setup.lang}"></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
expect(el.shadowRoot?.textContent?.trim()).to.equal(setup.result);
|
||||
});
|
||||
});
|
||||
|
@ -66,14 +64,12 @@ describe('<sl-format-date>', () => {
|
|||
const weekdays = ['narrow', 'short', 'long'];
|
||||
weekdays.forEach((weekdayFormat: 'narrow' | 'short' | 'long') => {
|
||||
it(`date has correct weekday format: ${weekdayFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date
|
||||
.date="${new Date(new Date().getFullYear(), 0, 1)}"
|
||||
weekday="${weekdayFormat}"
|
||||
></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { weekday: weekdayFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -87,11 +83,9 @@ describe('<sl-format-date>', () => {
|
|||
const eras = ['narrow', 'short', 'long'];
|
||||
eras.forEach((eraFormat: 'narrow' | 'short' | 'long') => {
|
||||
it(`date has correct era format: ${eraFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" era="${eraFormat}"></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { era: eraFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -105,11 +99,9 @@ describe('<sl-format-date>', () => {
|
|||
const yearFormats = ['numeric', '2-digit'];
|
||||
yearFormats.forEach((yearFormat: 'numeric' | '2-digit') => {
|
||||
it(`date has correct year format: ${yearFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" year="${yearFormat}"></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { year: yearFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -123,11 +115,9 @@ describe('<sl-format-date>', () => {
|
|||
const monthFormats = ['numeric', '2-digit', 'narrow', 'short', 'long'];
|
||||
monthFormats.forEach((monthFormat: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long') => {
|
||||
it(`date has correct month format: ${monthFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" month="${monthFormat}"></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { month: monthFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -141,11 +131,9 @@ describe('<sl-format-date>', () => {
|
|||
const dayFormats = ['numeric', '2-digit'];
|
||||
dayFormats.forEach((dayFormat: 'numeric' | '2-digit') => {
|
||||
it(`date has correct day format: ${dayFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" day="${dayFormat}"></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { day: dayFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -159,11 +147,9 @@ describe('<sl-format-date>', () => {
|
|||
const hourFormats = ['numeric', '2-digit'];
|
||||
hourFormats.forEach((hourFormat: 'numeric' | '2-digit') => {
|
||||
it(`date has correct hour format: ${hourFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" hour="${hourFormat}"></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { hour: hourFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -177,14 +163,9 @@ describe('<sl-format-date>', () => {
|
|||
const minuteFormats = ['numeric', '2-digit'];
|
||||
minuteFormats.forEach((minuteFormat: 'numeric' | '2-digit') => {
|
||||
it(`date has correct minute format: ${minuteFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
<sl-format-date
|
||||
.date="${new Date(new Date().getFullYear(), 0, 1)}"
|
||||
minute="${minuteFormat}"
|
||||
></sl-format-date>
|
||||
`
|
||||
);
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" minute="${minuteFormat}"></sl-format-date>
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { minute: minuteFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -198,14 +179,9 @@ describe('<sl-format-date>', () => {
|
|||
const secondFormats = ['numeric', '2-digit'];
|
||||
secondFormats.forEach((secondFormat: 'numeric' | '2-digit') => {
|
||||
it(`date has correct second format: ${secondFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
<sl-format-date
|
||||
.date="${new Date(new Date().getFullYear(), 0, 1)}"
|
||||
second="${secondFormat}"
|
||||
></sl-format-date>
|
||||
`
|
||||
);
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" second="${secondFormat}"></sl-format-date>
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { second: secondFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -219,14 +195,12 @@ describe('<sl-format-date>', () => {
|
|||
const timeZoneNameFormats = ['short', 'long'];
|
||||
timeZoneNameFormats.forEach((timeZoneNameFormat: 'short' | 'long') => {
|
||||
it(`date has correct timeZoneName format: ${timeZoneNameFormat}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date
|
||||
.date="${new Date(new Date().getFullYear(), 0, 1)}"
|
||||
time-zone-name="${timeZoneNameFormat}"
|
||||
></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { timeZoneName: timeZoneNameFormat }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -240,14 +214,9 @@ describe('<sl-format-date>', () => {
|
|||
const timeZones = ['America/New_York', 'America/Los_Angeles', 'Europe/Zurich'];
|
||||
timeZones.forEach(timeZone => {
|
||||
it(`date has correct timeZoneName format: ${timeZone}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
<sl-format-date
|
||||
.date="${new Date(new Date().getFullYear(), 0, 1)}"
|
||||
time-zone="${timeZone}"
|
||||
></sl-format-date>
|
||||
`
|
||||
);
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" time-zone="${timeZone}"></sl-format-date>
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', { timeZone: timeZone }).format(
|
||||
new Date(new Date().getFullYear(), 0, 1)
|
||||
|
@ -261,14 +230,12 @@ describe('<sl-format-date>', () => {
|
|||
const hourFormatValues = ['auto', '12', '24'];
|
||||
hourFormatValues.forEach(hourFormatValue => {
|
||||
it(`date has correct hourFormat format: ${hourFormatValue}`, async () => {
|
||||
const el = await fixture<SlFormatDate>(
|
||||
html`
|
||||
const el = await fixture<SlFormatDate>(html`
|
||||
<sl-format-date
|
||||
.date="${new Date(new Date().getFullYear(), 0, 1)}"
|
||||
hour-format="${hourFormatValue as 'auto' | '12' | '24'}"
|
||||
></sl-format-date>
|
||||
`
|
||||
);
|
||||
`);
|
||||
|
||||
const expected = new Intl.DateTimeFormat('en-US', {
|
||||
hour12: hourFormatValue === 'auto' ? undefined : hourFormatValue === '12'
|
||||
|
|
|
@ -24,9 +24,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('lang property', () => {
|
||||
['de', 'de-CH', 'fr', 'es', 'he', 'ja', 'nl', 'pl', 'pt', 'ru'].forEach(lang => {
|
||||
it(`number has correct language format: ${lang}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html` <sl-format-number value="1000" lang="${lang}"></sl-format-number> `
|
||||
);
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" lang="${lang}"></sl-format-number>
|
||||
`);
|
||||
const expected = new Intl.NumberFormat(lang, { style: 'decimal', useGrouping: true }).format(1000);
|
||||
expect(el.shadowRoot?.textContent).to.equal(expected);
|
||||
});
|
||||
|
@ -36,9 +36,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('type property', () => {
|
||||
['currency', 'decimal', 'percent'].forEach(type => {
|
||||
it(`number has correct type format: ${type}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html` <sl-format-number value="1000" type="${type}"></sl-format-number> `
|
||||
);
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" type="${type}"></sl-format-number>
|
||||
`);
|
||||
const expected = new Intl.NumberFormat('en-US', { style: type, currency: 'USD' }).format(1000);
|
||||
expect(el.shadowRoot?.textContent).to.equal(expected);
|
||||
});
|
||||
|
@ -62,9 +62,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('currency property', () => {
|
||||
['USD', 'CAD', 'AUD', 'UAH'].forEach(currency => {
|
||||
it(`number has correct type format: ${currency}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html` <sl-format-number value="1000" currency="${currency}"></sl-format-number> `
|
||||
);
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" currency="${currency}"></sl-format-number>
|
||||
`);
|
||||
const expected = new Intl.NumberFormat('en-US', { style: 'decimal', currency: currency }).format(1000);
|
||||
expect(el.shadowRoot?.textContent).to.equal(expected);
|
||||
});
|
||||
|
@ -74,9 +74,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('currencyDisplay property', () => {
|
||||
['symbol', 'narrowSymbol', 'code', 'name'].forEach(currencyDisplay => {
|
||||
it(`number has correct type format: ${currencyDisplay}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html` <sl-format-number value="1000" currency-display="${currencyDisplay}"></sl-format-number> `
|
||||
);
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" currency-display="${currencyDisplay}"></sl-format-number>
|
||||
`);
|
||||
const expected = new Intl.NumberFormat('en-US', { style: 'decimal', currencyDisplay: currencyDisplay }).format(
|
||||
1000
|
||||
);
|
||||
|
@ -88,9 +88,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('minimumIntegerDigits property', () => {
|
||||
[4, 5, 6].forEach(minDigits => {
|
||||
it(`number has correct type format: ${minDigits}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html` <sl-format-number value="1000" minimum-integer-digits="${minDigits}"></sl-format-number> `
|
||||
);
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" minimum-integer-digits="${minDigits}"></sl-format-number>
|
||||
`);
|
||||
const expected = new Intl.NumberFormat('en-US', {
|
||||
style: 'decimal',
|
||||
currencyDisplay: 'symbol',
|
||||
|
@ -104,9 +104,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('minimumFractionDigits property', () => {
|
||||
[4, 5, 6].forEach(minFractionDigits => {
|
||||
it(`number has correct type format: ${minFractionDigits}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html` <sl-format-number value="1000" minimum-fraction-digits="${minFractionDigits}"></sl-format-number> `
|
||||
);
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" minimum-fraction-digits="${minFractionDigits}"></sl-format-number>
|
||||
`);
|
||||
const expected = new Intl.NumberFormat('en-US', {
|
||||
style: 'decimal',
|
||||
currencyDisplay: 'symbol',
|
||||
|
@ -120,9 +120,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('maximumFractionDigits property', () => {
|
||||
[4, 5, 6].forEach(maxFractionDigits => {
|
||||
it(`number has correct type format: ${maxFractionDigits}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html` <sl-format-number value="1000" maximum-fraction-digits="${maxFractionDigits}"></sl-format-number> `
|
||||
);
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" maximum-fraction-digits="${maxFractionDigits}"></sl-format-number>
|
||||
`);
|
||||
const expected = new Intl.NumberFormat('en-US', {
|
||||
style: 'decimal',
|
||||
currencyDisplay: 'symbol',
|
||||
|
@ -136,11 +136,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('minimumSignificantDigits property', () => {
|
||||
[4, 5, 6].forEach(minSignificantDigits => {
|
||||
it(`number has correct type format: ${minSignificantDigits}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html`
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" minimum-significant-digits="${minSignificantDigits}"></sl-format-number>
|
||||
`
|
||||
);
|
||||
`);
|
||||
const expected = new Intl.NumberFormat('en-US', {
|
||||
style: 'decimal',
|
||||
currencyDisplay: 'symbol',
|
||||
|
@ -154,11 +152,9 @@ describe('<sl-format-number>', () => {
|
|||
describe('maximumSignificantDigits property', () => {
|
||||
[4, 5, 6].forEach(maxSignificantDigits => {
|
||||
it(`number has correct type format: ${maxSignificantDigits}`, async () => {
|
||||
const el = await fixture<SlFormatNumber>(
|
||||
html`
|
||||
const el = await fixture<SlFormatNumber>(html`
|
||||
<sl-format-number value="1000" maximum-significant-digits="${maxSignificantDigits}"></sl-format-number>
|
||||
`
|
||||
);
|
||||
`);
|
||||
const expected = new Intl.NumberFormat('en-US', {
|
||||
style: 'decimal',
|
||||
currencyDisplay: 'symbol',
|
||||
|
|
|
@ -30,15 +30,13 @@ describe('<sl-icon-button>', () => {
|
|||
|
||||
describe('when styling the host element', () => {
|
||||
it('renders the correct color and font size', async () => {
|
||||
const el = await fixture<SlIconButton>(
|
||||
html`
|
||||
const el = await fixture<SlIconButton>(html`
|
||||
<sl-icon-button
|
||||
library="system"
|
||||
name="check"
|
||||
style="color: rgb(0, 136, 221); font-size: 2rem;"
|
||||
></sl-icon-button>
|
||||
`
|
||||
);
|
||||
`);
|
||||
const icon = el.shadowRoot!.querySelector('sl-icon')!;
|
||||
const styles = getComputedStyle(icon);
|
||||
|
||||
|
@ -85,16 +83,16 @@ describe('<sl-icon-button>', () => {
|
|||
describe('and target is present', () => {
|
||||
['_blank', '_parent', '_self', '_top'].forEach((target: LinkTarget) => {
|
||||
it(`the anchor target is the provided target: ${target}`, async () => {
|
||||
const el = await fixture<SlIconButton>(
|
||||
html` <sl-icon-button href="some/path" target="${target}"></sl-icon-button> `
|
||||
);
|
||||
const el = await fixture<SlIconButton>(html`
|
||||
<sl-icon-button href="some/path" target="${target}"></sl-icon-button>
|
||||
`);
|
||||
expect(el.shadowRoot?.querySelector(`a[target="${target}"]`)).to.exist;
|
||||
});
|
||||
|
||||
it(`the anchor rel is set to 'noreferrer noopener'`, async () => {
|
||||
const el = await fixture<SlIconButton>(
|
||||
html` <sl-icon-button href="some/path" target="${target}"></sl-icon-button> `
|
||||
);
|
||||
const el = await fixture<SlIconButton>(html`
|
||||
<sl-icon-button href="some/path" target="${target}"></sl-icon-button>
|
||||
`);
|
||||
expect(el.shadowRoot?.querySelector(`a[rel="noreferrer noopener"]`)).to.exist;
|
||||
});
|
||||
});
|
||||
|
@ -103,9 +101,9 @@ describe('<sl-icon-button>', () => {
|
|||
describe('and download is present', () => {
|
||||
it(`the anchor download attribute is the provided download`, async () => {
|
||||
const fakeDownload = 'some/path';
|
||||
const el = await fixture<SlIconButton>(
|
||||
html` <sl-icon-button href="some/path" download="${fakeDownload}"></sl-icon-button> `
|
||||
);
|
||||
const el = await fixture<SlIconButton>(html`
|
||||
<sl-icon-button href="some/path" download="${fakeDownload}"></sl-icon-button>
|
||||
`);
|
||||
|
||||
expect(el.shadowRoot?.querySelector(`a[download="${fakeDownload}"]`)).to.exist;
|
||||
});
|
||||
|
@ -121,9 +119,9 @@ describe('<sl-icon-button>', () => {
|
|||
|
||||
it('the internal aria-label attribute is set to the provided label when rendering an anchor', async () => {
|
||||
const fakeLabel = 'some label';
|
||||
const el = await fixture<SlIconButton>(
|
||||
html` <sl-icon-button href="some/path" label="${fakeLabel}"></sl-icon-button> `
|
||||
);
|
||||
const el = await fixture<SlIconButton>(html`
|
||||
<sl-icon-button href="some/path" label="${fakeLabel}"></sl-icon-button>
|
||||
`);
|
||||
expect(el.shadowRoot?.querySelector(`a[aria-label="${fakeLabel}"]`)).to.exist;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -41,8 +41,8 @@ const icons = {
|
|||
</svg>
|
||||
`,
|
||||
copy: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-files" viewBox="0 0 16 16" part="svg">
|
||||
<path d="M13 0H6a2 2 0 0 0-2 2 2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2 2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 13V4a2 2 0 0 0-2-2H5a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zM3 4a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"></path>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-copy" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2Zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6ZM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1H2Z"/>
|
||||
</svg>
|
||||
`,
|
||||
eye: `
|
||||
|
|
|
@ -231,8 +231,8 @@ describe('<sl-image-comparer>', () => {
|
|||
const handle = el.shadowRoot!.querySelector<HTMLElement>('[part~="handle"]')!;
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
const rect = base.getBoundingClientRect();
|
||||
const offsetX = rect.left + window.pageXOffset;
|
||||
const offsetY = rect.top + window.pageYOffset;
|
||||
const offsetX = rect.left + window.scrollX;
|
||||
const offsetY = rect.top + window.scrollY;
|
||||
|
||||
handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
|
||||
|
|
|
@ -23,7 +23,10 @@ export default css`
|
|||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
cursor: text;
|
||||
transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow,
|
||||
transition:
|
||||
var(--sl-transition-fast) color,
|
||||
var(--sl-transition-fast) border,
|
||||
var(--sl-transition-fast) box-shadow,
|
||||
var(--sl-transition-fast) background-color;
|
||||
}
|
||||
|
||||
|
@ -129,6 +132,7 @@ export default css`
|
|||
.input__control::placeholder {
|
||||
color: var(--sl-input-placeholder-color);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.input:hover:not(.input--disabled) .input__control {
|
||||
|
|
|
@ -7,6 +7,14 @@ export default css`
|
|||
:host {
|
||||
--submenu-offset: -2px;
|
||||
|
||||
/* Private */
|
||||
--safe-triangle-cursor-x: 0;
|
||||
--safe-triangle-cursor-y: 0;
|
||||
--safe-triangle-submenu-start-x: 0;
|
||||
--safe-triangle-submenu-start-y: 0;
|
||||
--safe-triangle-submenu-end-x: 0;
|
||||
--safe-triangle-submenu-end-y: 0;
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
@ -27,6 +35,7 @@ export default css`
|
|||
padding: var(--sl-spacing-2x-small) var(--sl-spacing-2x-small);
|
||||
transition: var(--sl-transition-fast) fill;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -64,6 +73,22 @@ export default css`
|
|||
margin-inline-start: var(--sl-spacing-x-small);
|
||||
}
|
||||
|
||||
/* Safe triangle */
|
||||
.menu-item--submenu-expanded::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
z-index: calc(var(--sl-z-index-dropdown) - 1);
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
clip-path: polygon(
|
||||
var(--safe-triangle-cursor-x) var(--safe-triangle-cursor-y),
|
||||
var(--safe-triangle-submenu-start-x) var(--safe-triangle-submenu-start-y),
|
||||
var(--safe-triangle-submenu-end-x) var(--safe-triangle-submenu-end-y)
|
||||
);
|
||||
}
|
||||
|
||||
:host(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ export class SubmenuController implements ReactiveController {
|
|||
|
||||
private addListeners() {
|
||||
if (!this.isConnected) {
|
||||
this.host.addEventListener('mousemove', this.handleMouseMove);
|
||||
this.host.addEventListener('mouseover', this.handleMouseOver);
|
||||
this.host.addEventListener('keydown', this.handleKeyDown);
|
||||
this.host.addEventListener('click', this.handleClick);
|
||||
|
@ -61,6 +62,7 @@ export class SubmenuController implements ReactiveController {
|
|||
if (!this.isPopupConnected) {
|
||||
if (this.popupRef.value) {
|
||||
this.popupRef.value.addEventListener('mouseover', this.handlePopupMouseover);
|
||||
this.popupRef.value.addEventListener('sl-reposition', this.handlePopupReposition);
|
||||
this.isPopupConnected = true;
|
||||
}
|
||||
}
|
||||
|
@ -68,6 +70,7 @@ export class SubmenuController implements ReactiveController {
|
|||
|
||||
private removeListeners() {
|
||||
if (this.isConnected) {
|
||||
this.host.removeEventListener('mousemove', this.handleMouseMove);
|
||||
this.host.removeEventListener('mouseover', this.handleMouseOver);
|
||||
this.host.removeEventListener('keydown', this.handleKeyDown);
|
||||
this.host.removeEventListener('click', this.handleClick);
|
||||
|
@ -77,11 +80,18 @@ export class SubmenuController implements ReactiveController {
|
|||
if (this.isPopupConnected) {
|
||||
if (this.popupRef.value) {
|
||||
this.popupRef.value.removeEventListener('mouseover', this.handlePopupMouseover);
|
||||
this.popupRef.value.removeEventListener('sl-reposition', this.handlePopupReposition);
|
||||
this.isPopupConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the safe triangle cursor position
|
||||
private handleMouseMove = (event: MouseEvent) => {
|
||||
this.host.style.setProperty('--safe-triangle-cursor-x', `${event.clientX}px`);
|
||||
this.host.style.setProperty('--safe-triangle-cursor-y', `${event.clientY}px`);
|
||||
};
|
||||
|
||||
private handleMouseOver = () => {
|
||||
if (this.hasSlotController.test('submenu')) {
|
||||
this.enableSubmenu();
|
||||
|
@ -188,6 +198,24 @@ export class SubmenuController implements ReactiveController {
|
|||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// Set the safe triangle values for the submenu when the position changes
|
||||
private handlePopupReposition = () => {
|
||||
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
|
||||
const menu = submenuSlot?.assignedElements({ flatten: true }).filter(el => el.localName === 'sl-menu')[0];
|
||||
const isRtl = this.localize.dir() === 'rtl';
|
||||
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left, top, width, height } = menu.getBoundingClientRect();
|
||||
|
||||
this.host.style.setProperty('--safe-triangle-submenu-start-x', `${isRtl ? left + width : left}px`);
|
||||
this.host.style.setProperty('--safe-triangle-submenu-start-y', `${top}px`);
|
||||
this.host.style.setProperty('--safe-triangle-submenu-end-x', `${isRtl ? left + width : left}px`);
|
||||
this.host.style.setProperty('--safe-triangle-submenu-end-y', `${top + height}px`);
|
||||
};
|
||||
|
||||
private setSubmenuState(state: boolean) {
|
||||
if (this.popupRef.value) {
|
||||
if (this.popupRef.value.active !== state) {
|
||||
|
|
|
@ -18,5 +18,6 @@ export default css`
|
|||
color: var(--sl-color-neutral-500);
|
||||
padding: var(--sl-spacing-2x-small) var(--sl-spacing-x-large);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import ShoelaceElement from '../../internal/shoelace-element.js';
|
||||
import SlMenuItem from '../menu-item/menu-item.component.js';
|
||||
import styles from './menu.styles.js';
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
import type SlMenuItem from '../menu-item/menu-item.component.js';
|
||||
export interface MenuSelectEventDetail {
|
||||
item: SlMenuItem;
|
||||
}
|
||||
|
@ -29,11 +29,14 @@ export default class SlMenu extends ShoelaceElement {
|
|||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (!(event.target instanceof SlMenuItem)) {
|
||||
return;
|
||||
}
|
||||
const menuItemTypes = ['menuitem', 'menuitemcheckbox'];
|
||||
|
||||
const item: SlMenuItem = event.target;
|
||||
const target = event.composedPath().find((el: Element) => menuItemTypes.includes(el?.getAttribute?.('role') || ''));
|
||||
|
||||
if (!target) return;
|
||||
|
||||
// This isn't true. But we use it for TypeScript checks below.
|
||||
const item = target as SlMenuItem;
|
||||
|
||||
if (item.type === 'checkbox') {
|
||||
item.checked = !item.checked;
|
||||
|
|
|
@ -101,3 +101,23 @@ describe('<sl-menu>', () => {
|
|||
expect(selectHandler).to.not.have.been.called;
|
||||
});
|
||||
});
|
||||
|
||||
// @see https://github.com/shoelace-style/shoelace/issues/1596
|
||||
it('Should fire "sl-select" when clicking an element within a menu-item', async () => {
|
||||
// eslint-disable-next-line
|
||||
const selectHandler = sinon.spy(() => {});
|
||||
|
||||
const menu: SlMenu = await fixture(html`
|
||||
<sl-menu>
|
||||
<sl-menu-item>
|
||||
<span>Menu item</span>
|
||||
</sl-menu-item>
|
||||
</sl-menu>
|
||||
`);
|
||||
|
||||
menu.addEventListener('sl-select', selectHandler);
|
||||
const span = menu.querySelector('span')!;
|
||||
await clickOnElement(span);
|
||||
|
||||
expect(selectHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ export default css`
|
|||
:host {
|
||||
display: block;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
:host(:focus) {
|
||||
|
|
|
@ -33,8 +33,11 @@ export default css`
|
|||
line-height: var(--height);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
transition: 400ms width, 400ms background-color;
|
||||
transition:
|
||||
400ms width,
|
||||
400ms background-color;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Indeterminate */
|
||||
|
|
|
@ -73,12 +73,10 @@ describe('<sl-progress-bar>', () => {
|
|||
|
||||
describe('when provided a ariaLabelledBy, and value parameter', () => {
|
||||
before(async () => {
|
||||
el = await fixture<SlProgressBar>(
|
||||
html`
|
||||
el = await fixture<SlProgressBar>(html`
|
||||
<label id="labelledby">Progress Ring Label</label>
|
||||
<sl-progress-bar ariaLabelledBy="labelledby" value="25"></sl-progress-bar>
|
||||
`
|
||||
);
|
||||
`);
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', async () => {
|
||||
|
|
|
@ -66,5 +66,6 @@ export default css`
|
|||
height: 100%;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -52,12 +52,10 @@ describe('<sl-progress-ring>', () => {
|
|||
|
||||
describe('when provided a ariaLabelledBy, and value parameter', () => {
|
||||
before(async () => {
|
||||
el = await fixture<SlProgressRing>(
|
||||
html`
|
||||
el = await fixture<SlProgressRing>(html`
|
||||
<label id="labelledby">Progress Ring Label</label>
|
||||
<sl-progress-ring ariaLabelledBy="labelledby" value="25"></sl-progress-ring>
|
||||
`
|
||||
);
|
||||
`);
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', async () => {
|
||||
|
|
|
@ -56,8 +56,11 @@ export default css`
|
|||
border-radius: 50%;
|
||||
background-color: var(--sl-input-background-color);
|
||||
color: transparent;
|
||||
transition: var(--sl-transition-fast) border-color, var(--sl-transition-fast) background-color,
|
||||
var(--sl-transition-fast) color, var(--sl-transition-fast) box-shadow;
|
||||
transition:
|
||||
var(--sl-transition-fast) border-color,
|
||||
var(--sl-transition-fast) background-color,
|
||||
var(--sl-transition-fast) color,
|
||||
var(--sl-transition-fast) box-shadow;
|
||||
}
|
||||
|
||||
.radio__input {
|
||||
|
@ -110,5 +113,6 @@ export default css`
|
|||
line-height: var(--toggle-size);
|
||||
margin-inline-start: 0.5em;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -117,8 +117,11 @@ export default css`
|
|||
border-radius: 50%;
|
||||
background-color: var(--sl-color-primary-600);
|
||||
border-color: var(--sl-color-primary-600);
|
||||
transition: var(--sl-transition-fast) border-color, var(--sl-transition-fast) background-color,
|
||||
var(--sl-transition-fast) color, var(--sl-transition-fast) box-shadow;
|
||||
transition:
|
||||
var(--sl-transition-fast) border-color,
|
||||
var(--sl-transition-fast) background-color,
|
||||
var(--sl-transition-fast) color,
|
||||
var(--sl-transition-fast) box-shadow;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
|
@ -20,9 +20,9 @@ const expectFormattedRelativeTimeToBe = async (relativeTime: SlRelativeTime, exp
|
|||
};
|
||||
|
||||
const createRelativeTimeWithDate = async (relativeDate: Date): Promise<SlRelativeTime> => {
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(
|
||||
html` <sl-relative-time lang="en-US"></sl-relative-time> `
|
||||
);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(html`
|
||||
<sl-relative-time lang="en-US"></sl-relative-time>
|
||||
`);
|
||||
relativeTime.date = relativeDate;
|
||||
return relativeTime;
|
||||
};
|
||||
|
@ -113,27 +113,27 @@ describe('sl-relative-time', () => {
|
|||
it(`shows the correct relative time given a String object: ${testCase.expectedOutput}`, async () => {
|
||||
const dateString = testCase.date.toISOString();
|
||||
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(
|
||||
html` <sl-relative-time lang="en-US" date="${dateString}"></sl-relative-time> `
|
||||
);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(html`
|
||||
<sl-relative-time lang="en-US" date="${dateString}"></sl-relative-time>
|
||||
`);
|
||||
|
||||
await expectFormattedRelativeTimeToBe(relativeTime, testCase.expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
it('always shows numeric if requested via numeric property', async () => {
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(
|
||||
html` <sl-relative-time lang="en-US" numeric="always"></sl-relative-time> `
|
||||
);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(html`
|
||||
<sl-relative-time lang="en-US" numeric="always"></sl-relative-time>
|
||||
`);
|
||||
relativeTime.date = yesterday;
|
||||
|
||||
await expectFormattedRelativeTimeToBe(relativeTime, '1 day ago');
|
||||
});
|
||||
|
||||
it('shows human readable form if appropriate and numeric property is auto', async () => {
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(
|
||||
html` <sl-relative-time lang="en-US" numeric="auto"></sl-relative-time> `
|
||||
);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(html`
|
||||
<sl-relative-time lang="en-US" numeric="auto"></sl-relative-time>
|
||||
`);
|
||||
relativeTime.date = yesterday;
|
||||
|
||||
await expectFormattedRelativeTimeToBe(relativeTime, 'yesterday');
|
||||
|
@ -150,9 +150,9 @@ describe('sl-relative-time', () => {
|
|||
|
||||
it('allows to use a short form of the unit', async () => {
|
||||
const twoYearsAgo = new Date(currentTime.getTime() - 2 * nonLeapYearInSeconds);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(
|
||||
html` <sl-relative-time lang="en-US" numeric="always" format="short"></sl-relative-time> `
|
||||
);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(html`
|
||||
<sl-relative-time lang="en-US" numeric="always" format="short"></sl-relative-time>
|
||||
`);
|
||||
relativeTime.date = twoYearsAgo;
|
||||
|
||||
await expectFormattedRelativeTimeToBe(relativeTime, '2 yr. ago');
|
||||
|
@ -160,18 +160,18 @@ describe('sl-relative-time', () => {
|
|||
|
||||
it('allows to use a long form of the unit', async () => {
|
||||
const twoYearsAgo = new Date(currentTime.getTime() - 2 * nonLeapYearInSeconds);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(
|
||||
html` <sl-relative-time lang="en-US" numeric="always" format="long"></sl-relative-time> `
|
||||
);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(html`
|
||||
<sl-relative-time lang="en-US" numeric="always" format="long"></sl-relative-time>
|
||||
`);
|
||||
relativeTime.date = twoYearsAgo;
|
||||
|
||||
await expectFormattedRelativeTimeToBe(relativeTime, '2 years ago');
|
||||
});
|
||||
|
||||
it('is formatted according to the requested locale', async () => {
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(
|
||||
html` <sl-relative-time lang="de-DE" numeric="auto"></sl-relative-time> `
|
||||
);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(html`
|
||||
<sl-relative-time lang="de-DE" numeric="auto"></sl-relative-time>
|
||||
`);
|
||||
relativeTime.date = yesterday;
|
||||
|
||||
await expectFormattedRelativeTimeToBe(relativeTime, 'gestern');
|
||||
|
@ -192,9 +192,9 @@ describe('sl-relative-time', () => {
|
|||
it('does not display a time element on invalid time string', async () => {
|
||||
const invalidDateString = 'thisIsNotATimeString';
|
||||
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(
|
||||
html` <sl-relative-time lang="en-US" date="${invalidDateString}"></sl-relative-time> `
|
||||
);
|
||||
const relativeTime: SlRelativeTime = await fixture<SlRelativeTime>(html`
|
||||
<sl-relative-time lang="en-US" date="${invalidDateString}"></sl-relative-time>
|
||||
`);
|
||||
|
||||
await relativeTime.updateComplete;
|
||||
expect(extractTimeElement(relativeTime)).to.be.null;
|
||||
|
|
|
@ -46,7 +46,10 @@ export default css`
|
|||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow,
|
||||
transition:
|
||||
var(--sl-transition-fast) color,
|
||||
var(--sl-transition-fast) border,
|
||||
var(--sl-transition-fast) box-shadow,
|
||||
var(--sl-transition-fast) background-color;
|
||||
}
|
||||
|
||||
|
@ -64,6 +67,10 @@ export default css`
|
|||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.select__display-input::placeholder {
|
||||
color: var(--sl-input-placeholder-color);
|
||||
}
|
||||
|
||||
.select:not(.select--disabled):hover .select__display-input {
|
||||
color: var(--sl-input-color-hover);
|
||||
}
|
||||
|
|
|
@ -29,7 +29,6 @@ export default css`
|
|||
.skeleton--sheen .skeleton__indicator {
|
||||
background: linear-gradient(270deg, var(--sheen-color), var(--color), var(--color), var(--sheen-color));
|
||||
background-size: 400% 100%;
|
||||
background-size: 400% 100%;
|
||||
animation: sheen 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
|
|
@ -43,19 +43,23 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('should be accessible', async () => {
|
||||
const splitPanel = await fixture(html`<sl-split-panel>
|
||||
const splitPanel = await fixture(
|
||||
html`<sl-split-panel>
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
await expect(splitPanel).to.be.accessible();
|
||||
});
|
||||
|
||||
it('should show both panels', async () => {
|
||||
const splitPanel = await fixture(html`<sl-split-panel>
|
||||
const splitPanel = await fixture(
|
||||
html`<sl-split-panel>
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
expect(splitPanel).to.contain.text('Start');
|
||||
expect(splitPanel).to.contain.text('End');
|
||||
|
@ -63,10 +67,12 @@ describe('<sl-split-panel>', () => {
|
|||
|
||||
describe('panel sizing horizontal', () => {
|
||||
it('has two evenly sized panels by default', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel>
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel>
|
||||
<div slot="start" data-testid="start-panel">Start</div>
|
||||
<div slot="end" data-testid="end-panel">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
|
||||
const endPanelWidth = getPanelWidth(splitPanel, 'end-panel');
|
||||
|
@ -75,10 +81,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('changes the sizing of the panels based on the position attribute', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel position="25">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel position="25">
|
||||
<div slot="start" data-testid="start-panel">Start</div>
|
||||
<div slot="end" data-testid="end-panel">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
|
||||
const endPanelWidth = getPanelWidth(splitPanel, 'end-panel');
|
||||
|
@ -87,10 +95,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('updates the position in pixels to the correct result', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel position="25">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel position="25">
|
||||
<div slot="start" data-testid="start-panel">Start</div>
|
||||
<div slot="end" data-testid="end-panel">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
splitPanel.position = 10;
|
||||
|
||||
|
@ -100,10 +110,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('emits the sl-reposition event on position change', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel>
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel>
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const repositionPromise = oneEvent(splitPanel, 'sl-reposition');
|
||||
splitPanel.position = 10;
|
||||
|
@ -111,10 +123,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('can be resized using the mouse', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel>
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel>
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const positionInPixels = splitPanel.positionInPixels;
|
||||
|
||||
|
@ -127,10 +141,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('cannot be resized if disabled', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel disabled>
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel disabled>
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const positionInPixels = splitPanel.positionInPixels;
|
||||
|
||||
|
@ -143,10 +159,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('snaps to predefined positions', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel>
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel>
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const positionInPixels = splitPanel.positionInPixels;
|
||||
splitPanel.snap = `${positionInPixels - 40}px`;
|
||||
|
@ -162,10 +180,12 @@ describe('<sl-split-panel>', () => {
|
|||
|
||||
describe('panel sizing vertical', () => {
|
||||
it('has two evenly sized panels by default', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel vertical style="height: 400px;">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel vertical style="height: 400px;">
|
||||
<div slot="start" data-testid="start-panel">Start</div>
|
||||
<div slot="end" data-testid="end-panel">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
|
||||
const endPanelHeight = getPanelHeight(splitPanel, 'end-panel');
|
||||
|
@ -174,10 +194,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('changes the sizing of the panels based on the position attribute', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel position="25" vertical style="height: 400px;">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel position="25" vertical style="height: 400px;">
|
||||
<div slot="start" data-testid="start-panel">Start</div>
|
||||
<div slot="end" data-testid="end-panel">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
|
||||
const endPanelHeight = getPanelHeight(splitPanel, 'end-panel');
|
||||
|
@ -186,10 +208,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('updates the position in pixels to the correct result', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel position="25" vertical style="height: 400px;">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel position="25" vertical style="height: 400px;">
|
||||
<div slot="start" data-testid="start-panel">Start</div>
|
||||
<div slot="end" data-testid="end-panel">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
splitPanel.position = 10;
|
||||
|
||||
|
@ -199,10 +223,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('emits the sl-reposition event on position change ', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel vertical style="height: 400px;">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel vertical style="height: 400px;">
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const repositionPromise = oneEvent(splitPanel, 'sl-reposition');
|
||||
splitPanel.position = 10;
|
||||
|
@ -210,10 +236,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('can be resized using the mouse ', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel vertical style="height: 400px;">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel vertical style="height: 400px;">
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const positionInPixels = splitPanel.positionInPixels;
|
||||
|
||||
|
@ -226,10 +254,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('cannot be resized if disabled', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel disabled vertical style="height: 400px;">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel disabled vertical style="height: 400px;">
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const positionInPixels = splitPanel.positionInPixels;
|
||||
|
||||
|
@ -242,10 +272,12 @@ describe('<sl-split-panel>', () => {
|
|||
});
|
||||
|
||||
it('snaps to predefined positions', async () => {
|
||||
const splitPanel = await fixture<SlSplitPanel>(html`<sl-split-panel vertical style="height: 400px;">
|
||||
const splitPanel = await fixture<SlSplitPanel>(
|
||||
html`<sl-split-panel vertical style="height: 400px;">
|
||||
<div slot="start">Start</div>
|
||||
<div slot="end">End</div>
|
||||
</sl-split-panel>`);
|
||||
</sl-split-panel>`
|
||||
);
|
||||
|
||||
const positionInPixels = splitPanel.positionInPixels;
|
||||
splitPanel.snap = `${positionInPixels - 40}px`;
|
||||
|
|
|
@ -55,7 +55,9 @@ export default css`
|
|||
background-color: var(--sl-color-neutral-400);
|
||||
border: solid var(--sl-input-border-width) var(--sl-color-neutral-400);
|
||||
border-radius: var(--height);
|
||||
transition: var(--sl-transition-fast) border-color, var(--sl-transition-fast) background-color;
|
||||
transition:
|
||||
var(--sl-transition-fast) border-color,
|
||||
var(--sl-transition-fast) background-color;
|
||||
}
|
||||
|
||||
.switch__control .switch__thumb {
|
||||
|
@ -65,8 +67,11 @@ export default css`
|
|||
border-radius: 50%;
|
||||
border: solid var(--sl-input-border-width) var(--sl-color-neutral-400);
|
||||
translate: calc((var(--width) - var(--height)) / -2);
|
||||
transition: var(--sl-transition-fast) translate ease, var(--sl-transition-fast) background-color,
|
||||
var(--sl-transition-fast) border-color, var(--sl-transition-fast) box-shadow;
|
||||
transition:
|
||||
var(--sl-transition-fast) translate ease,
|
||||
var(--sl-transition-fast) background-color,
|
||||
var(--sl-transition-fast) border-color,
|
||||
var(--sl-transition-fast) box-shadow;
|
||||
}
|
||||
|
||||
.switch__input {
|
||||
|
@ -148,6 +153,7 @@ export default css`
|
|||
line-height: var(--height);
|
||||
margin-inline-start: 0.5em;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
:host([required]) .switch__label::after {
|
||||
|
|
|
@ -24,7 +24,9 @@ export default css`
|
|||
|
||||
.tab-group__indicator {
|
||||
position: absolute;
|
||||
transition: var(--sl-transition-fast) translate ease, var(--sl-transition-fast) width ease;
|
||||
transition:
|
||||
var(--sl-transition-fast) translate ease,
|
||||
var(--sl-transition-fast) width ease;
|
||||
}
|
||||
|
||||
.tab-group--has-scroll-controls .tab-group__nav-container {
|
||||
|
|
|
@ -187,8 +187,10 @@ describe('<sl-tab-group>', () => {
|
|||
const generateTabs = (n: number): HTMLTemplateResult[] => {
|
||||
const result: HTMLTemplateResult[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
result.push(html`<sl-tab slot="nav" panel="tab-${i}">Tab ${i}</sl-tab>
|
||||
<sl-tab-panel name="tab-${i}">Content of tab ${i}0</sl-tab-panel> `);
|
||||
result.push(
|
||||
html`<sl-tab slot="nav" panel="tab-${i}">Tab ${i}</sl-tab>
|
||||
<sl-tab-panel name="tab-${i}">Content of tab ${i}0</sl-tab-panel> `
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
|
|
@ -19,8 +19,11 @@ export default css`
|
|||
padding: var(--sl-spacing-medium) var(--sl-spacing-large);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-speed) box-shadow, var(--transition-speed) color;
|
||||
transition:
|
||||
var(--transition-speed) box-shadow,
|
||||
var(--transition-speed) color;
|
||||
}
|
||||
|
||||
.tab:hover:not(.tab--disabled) {
|
||||
|
|
|
@ -15,6 +15,7 @@ export default css`
|
|||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.tag__remove::part(base) {
|
||||
|
|
|
@ -20,7 +20,10 @@ export default css`
|
|||
line-height: var(--sl-line-height-normal);
|
||||
letter-spacing: var(--sl-input-letter-spacing);
|
||||
vertical-align: middle;
|
||||
transition: var(--sl-transition-fast) color, var(--sl-transition-fast) border, var(--sl-transition-fast) box-shadow,
|
||||
transition:
|
||||
var(--sl-transition-fast) color,
|
||||
var(--sl-transition-fast) border,
|
||||
var(--sl-transition-fast) box-shadow,
|
||||
var(--sl-transition-fast) background-color;
|
||||
cursor: text;
|
||||
}
|
||||
|
@ -112,6 +115,7 @@ export default css`
|
|||
.textarea__control::placeholder {
|
||||
color: var(--sl-input-placeholder-color);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.textarea__control:focus {
|
||||
|
|
|
@ -145,7 +145,7 @@ export default class SlTooltip extends ShoelaceElement {
|
|||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Pressing escape when the target element has focus should dismiss the tooltip
|
||||
if (this.open && event.key === 'Escape') {
|
||||
if (this.open && !this.disabled && event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
this.hide();
|
||||
}
|
||||
|
|
|
@ -52,5 +52,6 @@ export default css`
|
|||
padding: var(--sl-tooltip-padding);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -267,8 +267,7 @@ export default class SlTreeItem extends ShoelaceElement {
|
|||
|
||||
${when(
|
||||
this.selectable,
|
||||
() =>
|
||||
html`
|
||||
() => html`
|
||||
<sl-checkbox
|
||||
part="checkbox"
|
||||
exportparts="
|
||||
|
|
|
@ -26,6 +26,7 @@ export default css`
|
|||
color: var(--sl-color-neutral-700);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.tree-item__checkbox {
|
||||
|
|
|
@ -20,3 +20,7 @@ export function* activeElements(activeElement: Element | null = document.activeE
|
|||
yield* activeElements(activeElement.shadowRoot.activeElement);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDeepestActiveElement() {
|
||||
return [...activeElements()].pop();
|
||||
}
|
||||
|
|
|
@ -16,8 +16,8 @@ export function drag(container: HTMLElement, options?: Partial<DragOptions>) {
|
|||
function move(pointerEvent: PointerEvent) {
|
||||
const dims = container.getBoundingClientRect();
|
||||
const defaultView = container.ownerDocument.defaultView!;
|
||||
const offsetX = dims.left + defaultView.pageXOffset;
|
||||
const offsetY = dims.top + defaultView.pageYOffset;
|
||||
const offsetX = dims.left + defaultView.scrollX;
|
||||
const offsetY = dims.top + defaultView.scrollY;
|
||||
const x = pointerEvent.pageX - offsetX;
|
||||
const y = pointerEvent.pageY - offsetY;
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import '../../../dist/shoelace.js';
|
||||
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
|
||||
// Reproduction of this issue: https://github.com/shoelace-style/shoelace/issues/1703
|
||||
it('Should still run form validations if an element is removed', async () => {
|
||||
const form = await fixture<HTMLFormElement>(html`
|
||||
<form>
|
||||
<sl-input name="name" label="Name" required></sl-input>
|
||||
<sl-textarea name="comment" label="Comment" required></sl-textarea>
|
||||
</form>
|
||||
`);
|
||||
|
||||
expect(form.checkValidity()).to.equal(false);
|
||||
expect(form.reportValidity()).to.equal(false);
|
||||
|
||||
form.querySelector('sl-input')!.remove();
|
||||
|
||||
expect(form.checkValidity()).to.equal(false);
|
||||
expect(form.reportValidity()).to.equal(false);
|
||||
});
|
|
@ -14,6 +14,7 @@ export const formCollections: WeakMap<HTMLFormElement, Set<ShoelaceFormControl>>
|
|||
// restore the original behavior when they disconnect.
|
||||
//
|
||||
const reportValidityOverloads: WeakMap<HTMLFormElement, () => boolean> = new WeakMap();
|
||||
const checkValidityOverloads: WeakMap<HTMLFormElement, () => boolean> = new WeakMap();
|
||||
|
||||
//
|
||||
// We store a Set of controls that users have interacted with. This allows us to determine the interaction state
|
||||
|
@ -42,6 +43,12 @@ export interface FormControlControllerOptions {
|
|||
* prevent submission and trigger the browser's constraint violation warning.
|
||||
*/
|
||||
reportValidity: (input: ShoelaceFormControl) => boolean;
|
||||
|
||||
/**
|
||||
* A function that maps to the form control's `checkValidity()` function. When the control is invalid, this will return false.
|
||||
* this is helpful is you want to check validation without triggering the native browser constraint violation warning.
|
||||
*/
|
||||
checkValidity: (input: ShoelaceFormControl) => boolean;
|
||||
/** A function that sets the form control's value */
|
||||
setValue: (input: ShoelaceFormControl, value: unknown) => void;
|
||||
/**
|
||||
|
@ -61,12 +68,16 @@ export class FormControlController implements ReactiveController {
|
|||
this.options = {
|
||||
form: input => {
|
||||
// If there's a form attribute, use it to find the target form by id
|
||||
if (input.hasAttribute('form') && input.getAttribute('form') !== '') {
|
||||
const root = input.getRootNode() as Document | ShadowRoot;
|
||||
const formId = input.getAttribute('form');
|
||||
// Controls may not always reflect the 'form' property. For example, `<sl-button>` doesn't reflect.
|
||||
const formId = input.form;
|
||||
|
||||
if (formId) {
|
||||
return root.getElementById(formId) as HTMLFormElement;
|
||||
const root = input.getRootNode() as Document | ShadowRoot;
|
||||
|
||||
const form = root.getElementById(formId);
|
||||
|
||||
if (form) {
|
||||
return form as HTMLFormElement;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,6 +88,7 @@ export class FormControlController implements ReactiveController {
|
|||
defaultValue: input => input.defaultValue,
|
||||
disabled: input => input.disabled ?? false,
|
||||
reportValidity: input => (typeof input.reportValidity === 'function' ? input.reportValidity() : true),
|
||||
checkValidity: input => (typeof input.checkValidity === 'function' ? input.checkValidity() : true),
|
||||
setValue: (input, value: string) => (input.value = value),
|
||||
assumeInteractionOn: ['sl-input'],
|
||||
...options
|
||||
|
@ -146,16 +158,34 @@ export class FormControlController implements ReactiveController {
|
|||
reportValidityOverloads.set(this.form, this.form.reportValidity);
|
||||
this.form.reportValidity = () => this.reportFormValidity();
|
||||
}
|
||||
|
||||
// Overload the form's checkValidity() method so it looks at Shoelace form controls
|
||||
if (!checkValidityOverloads.has(this.form)) {
|
||||
checkValidityOverloads.set(this.form, this.form.checkValidity);
|
||||
this.form.checkValidity = () => this.checkFormValidity();
|
||||
}
|
||||
} else {
|
||||
this.form = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private detachForm() {
|
||||
if (this.form) {
|
||||
// Remove this element from the form's collection
|
||||
formCollections.get(this.form)?.delete(this.host);
|
||||
if (!this.form) return;
|
||||
|
||||
const formCollection = formCollections.get(this.form);
|
||||
|
||||
if (!formCollection) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove this host from the form's collection
|
||||
formCollection.delete(this.host);
|
||||
|
||||
// Check to make sure there's no other form controls in the collection. If we do this
|
||||
// without checking if any other controls are still in the collection, then we will wipe out the
|
||||
// validity checks for all other elements.
|
||||
// see: https://github.com/shoelace-style/shoelace/issues/1703
|
||||
if (formCollection.size <= 0) {
|
||||
this.form.removeEventListener('formdata', this.handleFormData);
|
||||
this.form.removeEventListener('submit', this.handleFormSubmit);
|
||||
this.form.removeEventListener('reset', this.handleFormReset);
|
||||
|
@ -165,10 +195,18 @@ export class FormControlController implements ReactiveController {
|
|||
this.form.reportValidity = reportValidityOverloads.get(this.form)!;
|
||||
reportValidityOverloads.delete(this.form);
|
||||
}
|
||||
|
||||
if (checkValidityOverloads.has(this.form)) {
|
||||
this.form.checkValidity = checkValidityOverloads.get(this.form)!;
|
||||
checkValidityOverloads.delete(this.form);
|
||||
}
|
||||
|
||||
// So it looks weird here to not always set the form to undefined. But I _think_ if we unattach this.form here,
|
||||
// we end up in this fun spot where future validity checks don't have a reference to the form validity handler.
|
||||
// First form element in sets the validity handler. So we can't clean up `this.form` until there are no other form elements in the form.
|
||||
this.form = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private handleFormData = (event: FormDataEvent) => {
|
||||
const disabled = this.options.disabled(this.host);
|
||||
|
@ -226,6 +264,34 @@ export class FormControlController implements ReactiveController {
|
|||
}
|
||||
};
|
||||
|
||||
private checkFormValidity = () => {
|
||||
//
|
||||
// This is very similar to the `reportFormValidity` function, but it does not trigger native constraint validation
|
||||
// Allow the user to simply check if the form is valid and handling validity in their own way.
|
||||
//
|
||||
// We preserve the original method in a WeakMap, but we don't call it from the overload because that would trigger
|
||||
// validations in an unexpected order. When the element disconnects, we revert to the original behavior. This won't
|
||||
// be necessary once we can use ElementInternals.
|
||||
//
|
||||
// Note that we're also honoring the form's novalidate attribute.
|
||||
//
|
||||
if (this.form && !this.form.noValidate) {
|
||||
// This seems sloppy, but checking all elements will cover native inputs, Shoelace inputs, and other custom
|
||||
// elements that support the constraint validation API.
|
||||
const elements = this.form.querySelectorAll<HTMLInputElement>('*');
|
||||
|
||||
for (const element of elements) {
|
||||
if (typeof element.checkValidity === 'function') {
|
||||
if (!element.checkValidity()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private reportFormValidity = () => {
|
||||
//
|
||||
// Shoelace form controls work hard to act like regular form controls. They support the Constraint Validation API
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { activeElements } from './active-elements.js';
|
||||
import { getDeepestActiveElement } from './active-elements.js';
|
||||
import { getTabbableElements } from './tabbable.js';
|
||||
|
||||
let activeModals: HTMLElement[] = [];
|
||||
|
@ -63,27 +63,13 @@ export default class Modal {
|
|||
}
|
||||
|
||||
private handleFocusIn = () => {
|
||||
if (!this.isActive()) return;
|
||||
this.checkFocus();
|
||||
};
|
||||
|
||||
get currentFocusIndex() {
|
||||
return getTabbableElements(this.element).findIndex(el => el === this.currentFocus);
|
||||
}
|
||||
|
||||
// Checks if the `startElement` is already focused. This is important if the modal already has an existing focus prior
|
||||
// to the first tab key.
|
||||
private startElementAlreadyFocused(startElement: HTMLElement) {
|
||||
for (const activeElement of activeElements()) {
|
||||
if (startElement === activeElement) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab' || this.isExternalActivated) return;
|
||||
if (!this.isActive()) return;
|
||||
|
||||
if (event.shiftKey) {
|
||||
this.tabDirection = 'backward';
|
||||
|
@ -94,29 +80,30 @@ export default class Modal {
|
|||
event.preventDefault();
|
||||
|
||||
const tabbableElements = getTabbableElements(this.element);
|
||||
const start = tabbableElements[0];
|
||||
|
||||
// Sometimes we programmatically focus the first element in a modal.
|
||||
// Lets make sure the start element isn't already focused.
|
||||
let focusIndex = this.startElementAlreadyFocused(start) ? 0 : this.currentFocusIndex;
|
||||
// Because sometimes focus can actually be taken over from outside sources,
|
||||
// we don't want to rely on `this.currentFocus`. Instead we check the actual `activeElement` and
|
||||
// recurse through shadowRoots.
|
||||
const currentActiveElement = getDeepestActiveElement();
|
||||
let currentFocusIndex = tabbableElements.findIndex(el => el === currentActiveElement);
|
||||
|
||||
if (focusIndex === -1) {
|
||||
this.currentFocus = start;
|
||||
this.currentFocus.focus({ preventScroll: true });
|
||||
if (currentFocusIndex === -1) {
|
||||
this.currentFocus = tabbableElements[0];
|
||||
this.currentFocus?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const addition = this.tabDirection === 'forward' ? 1 : -1;
|
||||
|
||||
if (focusIndex + addition >= tabbableElements.length) {
|
||||
focusIndex = 0;
|
||||
} else if (this.currentFocusIndex + addition < 0) {
|
||||
focusIndex = tabbableElements.length - 1;
|
||||
if (currentFocusIndex + addition >= tabbableElements.length) {
|
||||
currentFocusIndex = 0;
|
||||
} else if (currentFocusIndex + addition < 0) {
|
||||
currentFocusIndex = tabbableElements.length - 1;
|
||||
} else {
|
||||
focusIndex += addition;
|
||||
currentFocusIndex += addition;
|
||||
}
|
||||
|
||||
this.currentFocus = tabbableElements[focusIndex];
|
||||
this.currentFocus = tabbableElements[currentFocusIndex];
|
||||
this.currentFocus?.focus({ preventScroll: true });
|
||||
|
||||
setTimeout(() => this.checkFocus());
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
type GenericCallback = (this: unknown, ...args: unknown[]) => unknown;
|
||||
|
||||
type MethodOf<T, K extends keyof T> = T[K] extends GenericCallback ? T[K] : never;
|
||||
|
||||
const debounce = <T extends GenericCallback>(fn: T, delay: number) => {
|
||||
let timerId = 0;
|
||||
|
||||
return function (this: unknown, ...args: unknown[]) {
|
||||
window.clearTimeout(timerId);
|
||||
timerId = window.setTimeout(() => {
|
||||
fn.call(this, ...args);
|
||||
}, delay);
|
||||
};
|
||||
};
|
||||
|
||||
const decorate = <T, M extends keyof T>(
|
||||
proto: T,
|
||||
method: M,
|
||||
decorateFn: (this: unknown, superFn: T[M], ...args: unknown[]) => unknown
|
||||
) => {
|
||||
const superFn = proto[method] as MethodOf<T, M>;
|
||||
|
||||
proto[method] = function (this: unknown, ...args: unknown[]) {
|
||||
superFn.call(this, ...args);
|
||||
decorateFn.call(this, superFn, ...args);
|
||||
} as MethodOf<T, M>;
|
||||
};
|
||||
|
||||
const isSupported = 'onscrollend' in window;
|
||||
|
||||
if (!isSupported) {
|
||||
const pointers = new Set();
|
||||
const scrollHandlers = new WeakMap<EventTarget, EventListenerOrEventListenerObject>();
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
pointers.add(event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
pointers.delete(event.pointerId);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
|
||||
decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) {
|
||||
if (type !== 'scroll') return;
|
||||
|
||||
const handleScrollEnd = debounce(() => {
|
||||
if (!pointers.size) {
|
||||
// If no pointer is active in the scroll area then the scroll has ended
|
||||
this.dispatchEvent(new Event('scrollend'));
|
||||
} else {
|
||||
// otherwise let's wait a bit more
|
||||
handleScrollEnd();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
addEventListener.call(this, 'scroll', handleScrollEnd, { passive: true });
|
||||
scrollHandlers.set(this, handleScrollEnd);
|
||||
});
|
||||
|
||||
decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) {
|
||||
if (type !== 'scroll') return;
|
||||
|
||||
const scrollHandler = scrollHandlers.get(this);
|
||||
if (scrollHandler) {
|
||||
removeEventListener.call(this, 'scroll', scrollHandler, { passive: true } as unknown as EventListenerOptions);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Without an import or export, TypeScript sees vars in this file as global
|
||||
export {};
|
|
@ -1,9 +1,12 @@
|
|||
import { elementUpdated, expect, fixture } from '@open-wc/testing';
|
||||
import { aTimeout, elementUpdated, expect, fixture } from '@open-wc/testing';
|
||||
|
||||
import '../../dist/shoelace.js';
|
||||
import { activeElements } from './active-elements.js';
|
||||
import { activeElements, getDeepestActiveElement } from './active-elements.js';
|
||||
import { clickOnElement } from './test.js';
|
||||
import { html } from 'lit';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import type { SlDialog } from '../shoelace.js';
|
||||
|
||||
import '../../../dist/shoelace.js';
|
||||
|
||||
async function holdShiftKey(callback: () => Promise<void>) {
|
||||
await sendKeys({ down: 'Shift' });
|
||||
|
@ -19,10 +22,6 @@ function activeElementsArray() {
|
|||
return [...activeElements()];
|
||||
}
|
||||
|
||||
function getDeepestActiveElement() {
|
||||
return activeElementsArray().pop();
|
||||
}
|
||||
|
||||
window.customElements.define(
|
||||
'tab-test-1',
|
||||
class extends HTMLElement {
|
||||
|
@ -145,3 +144,76 @@ it('Should allow tabbing to slotted elements', async () => {
|
|||
await holdShiftKey(async () => await sendKeys({ press: tabKey }));
|
||||
expect(activeElementsArray()).to.include(focusSix);
|
||||
});
|
||||
|
||||
it('Should account for when focus is changed from outside sources (like clicking)', async () => {
|
||||
const dialog = await fixture(html`
|
||||
<sl-dialog open="" label="Dialog" class="dialog-overview">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
<sl-input placeholder="tab to me"></sl-input>
|
||||
<sl-button slot="footer" variant="primary">Close</sl-button>
|
||||
</sl-dialog>
|
||||
`);
|
||||
|
||||
const inputEl = dialog.querySelector('sl-input')!;
|
||||
const closeButton = dialog.shadowRoot!.querySelector('sl-icon-button')!;
|
||||
const footerButton = dialog.querySelector('sl-button')!;
|
||||
|
||||
expect(activeElementsArray()).to.not.include(inputEl);
|
||||
|
||||
// Sets focus to the input element
|
||||
inputEl.focus();
|
||||
|
||||
expect(activeElementsArray()).to.include(inputEl);
|
||||
|
||||
await sendKeys({ press: tabKey });
|
||||
|
||||
expect(activeElementsArray()).not.to.include(inputEl);
|
||||
expect(activeElementsArray()).to.include(footerButton);
|
||||
|
||||
// Reset focus back to input el
|
||||
inputEl.focus();
|
||||
expect(activeElementsArray()).to.include(inputEl);
|
||||
|
||||
await holdShiftKey(async () => await sendKeys({ press: tabKey }));
|
||||
expect(activeElementsArray()).to.include(closeButton);
|
||||
});
|
||||
|
||||
// https://github.com/shoelace-style/shoelace/issues/1710
|
||||
it('Should respect nested modal instances', async () => {
|
||||
const dialogOne = (): SlDialog => document.querySelector('#dialog-1')!;
|
||||
const dialogTwo = (): SlDialog => document.querySelector('#dialog-2')!;
|
||||
|
||||
// lit-a11y doesn't like the "autofocus" attribute.
|
||||
/* eslint-disable */
|
||||
await fixture(html`
|
||||
<div>
|
||||
<sl-button id="open-dialog-1" @click=${() => dialogOne().show()}></sl-button>
|
||||
<sl-dialog id="dialog-1" label="Dialog 1">
|
||||
<sl-button @click=${() => dialogTwo().show()} id="open-dialog-2">Open Dialog 2</sl-button>
|
||||
<sl-button slot="footer" variant="primary">Close</sl-button>
|
||||
</sl-dialog>
|
||||
|
||||
<sl-dialog id="dialog-2" label="Dialog 2">
|
||||
<sl-input id="focus-1" autofocus="" placeholder="I will have focus when the dialog is opened"></sl-input>
|
||||
<sl-input id="focus-2" placeholder="Second input"></sl-input>
|
||||
<sl-button slot="footer" variant="primary" class="close-2">Close</sl-button>
|
||||
</sl-dialog>
|
||||
</div>
|
||||
`);
|
||||
/* eslint-enable */
|
||||
|
||||
const firstFocusedEl = document.querySelector('#focus-1');
|
||||
const secondFocusedEl = document.querySelector('#focus-2');
|
||||
|
||||
// So we can trigger auto-focus stuff
|
||||
await clickOnElement(document.querySelector('#open-dialog-1')!);
|
||||
// These clicks need a ~100ms timeout. I'm assuming for animation reasons?
|
||||
await aTimeout(100);
|
||||
await clickOnElement(document.querySelector('#open-dialog-2')!);
|
||||
await aTimeout(100);
|
||||
|
||||
expect(activeElementsArray()).to.include(firstFocusedEl);
|
||||
|
||||
await sendKeys({ press: tabKey });
|
||||
expect(activeElementsArray()).to.include(secondFocusedEl);
|
||||
});
|
||||
|
|
|
@ -1,4 +1,15 @@
|
|||
import { offsetParent } from 'composed-offset-position';
|
||||
//
|
||||
// This doesn't technically check visibility, it checks if the element has been rendered and can maybe possibly be tabbed
|
||||
// to. This is a workaround for shadow roots not having an `offsetParent`.
|
||||
//
|
||||
// See https://stackoverflow.com/questions/19669786/check-if-element-is-visible-in-dom
|
||||
//
|
||||
// Previously, we used https://www.npmjs.com/package/composed-offset-position, but recursing up an entire node tree took
|
||||
// up a lot of CPU cycles and made focus traps unusable in Chrome / Edge.
|
||||
//
|
||||
function isTakingUpSpace(elem: HTMLElement): boolean {
|
||||
return Boolean(elem.offsetParent || elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length);
|
||||
}
|
||||
|
||||
/** Determines if the specified element is tabbable using heuristics inspired by https://github.com/focus-trap/tabbable */
|
||||
function isTabbable(el: HTMLElement) {
|
||||
|
@ -14,19 +25,13 @@ function isTabbable(el: HTMLElement) {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Elements with aria-disabled are not tabbable
|
||||
if (el.hasAttribute('aria-disabled') && el.getAttribute('aria-disabled') !== 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Radios without a checked attribute are not tabbable
|
||||
if (tag === 'input' && el.getAttribute('type') === 'radio' && !el.hasAttribute('checked')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Elements that are hidden have no offsetParent and are not tabbable
|
||||
// offsetParent() is added because otherwise it misses elements in Safari
|
||||
if (el.offsetParent === null && offsetParent(el) === null) {
|
||||
if (!isTakingUpSpace(el)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -107,14 +112,12 @@ export function getTabbableElements(root: HTMLElement | ShadowRoot) {
|
|||
// Collect all elements including the root
|
||||
walk(root);
|
||||
|
||||
return tabbableElements;
|
||||
|
||||
// Is this worth having? Most sorts will always add increased overhead. And positive tabindexes shouldn't really be used.
|
||||
// So is it worth being right? Or fast?
|
||||
// return tabbableElements.filter(isTabbable).sort((a, b) => {
|
||||
// // Make sure we sort by tabindex.
|
||||
// const aTabindex = Number(a.getAttribute('tabindex')) || 0;
|
||||
// const bTabindex = Number(b.getAttribute('tabindex')) || 0;
|
||||
// return bTabindex - aTabindex;
|
||||
// });
|
||||
return tabbableElements.sort((a, b) => {
|
||||
// Make sure we sort by tabindex.
|
||||
const aTabindex = Number(a.getAttribute('tabindex')) || 0;
|
||||
const bTabindex = Number(b.getAttribute('tabindex')) || 0;
|
||||
return bTabindex - aTabindex;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,8 +2,8 @@ import { sendMouse } from '@web/test-runner-commands';
|
|||
|
||||
function determineMousePosition(el: Element, position: string, offsetX: number, offsetY: number) {
|
||||
const { x, y, width, height } = el.getBoundingClientRect();
|
||||
const centerX = Math.floor(x + window.pageXOffset + width / 2);
|
||||
const centerY = Math.floor(y + window.pageYOffset + height / 2);
|
||||
const centerX = Math.floor(x + window.scrollX + width / 2);
|
||||
const centerY = Math.floor(y + window.scrollY + height / 2);
|
||||
let clickX: number;
|
||||
let clickY: number;
|
||||
|
||||
|
@ -73,11 +73,21 @@ export async function dragElement(
|
|||
/** The horizontal distance to drag in pixels */
|
||||
deltaX = 0,
|
||||
/** The vertical distance to drag in pixels */
|
||||
deltaY = 0
|
||||
deltaY = 0,
|
||||
callbacks: {
|
||||
afterMouseDown?: () => void | Promise<void>;
|
||||
afterMouseMove?: () => void | Promise<void>;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
await moveMouseOnElement(el);
|
||||
await sendMouse({ type: 'down' });
|
||||
|
||||
await callbacks.afterMouseDown?.();
|
||||
|
||||
const { clickX, clickY } = determineMousePosition(el, 'center', deltaX, deltaY);
|
||||
await sendMouse({ type: 'move', position: [clickX, clickY] });
|
||||
|
||||
await callbacks.afterMouseMove?.();
|
||||
|
||||
await sendMouse({ type: 'up' });
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ export function runFormControlBaseTests<T extends ShoelaceFormControl = Shoelace
|
|||
// - `.checkValidity()`
|
||||
// - `.reportValidity()`
|
||||
// - `.setCustomValidity(msg)`
|
||||
// - `.getForm()`
|
||||
//
|
||||
function runAllValidityTests(
|
||||
tagName: string, //
|
||||
|
@ -124,6 +125,27 @@ function runAllValidityTests(
|
|||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
|
||||
it('Should find the correct form when given a form property', async () => {
|
||||
const formId = 'test-form';
|
||||
const form = await fixture(`<form id='${formId}'></form>`);
|
||||
const control = await createControl();
|
||||
expect(control.getForm()).to.equal(null);
|
||||
control.form = 'test-form';
|
||||
await control.updateComplete;
|
||||
expect(control.getForm()).to.equal(form);
|
||||
});
|
||||
|
||||
it('Should find the correct form when given a form attribute', async () => {
|
||||
const formId = 'test-form';
|
||||
const form = await fixture(`<form id='${formId}'></form>`);
|
||||
const control = await createControl();
|
||||
expect(control.getForm()).to.equal(null);
|
||||
control.setAttribute('form', 'test-form');
|
||||
|
||||
await control.updateComplete;
|
||||
expect(control.getForm()).to.equal(form);
|
||||
});
|
||||
}
|
||||
|
||||
// Run special tests depending on component type
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import { registerTranslation } from '@shoelace-style/localize';
|
||||
import type { Translation } from '../utilities/localize.js';
|
||||
|
||||
const translation: Translation = {
|
||||
$code: 'hr',
|
||||
$name: 'Hrvatski',
|
||||
$dir: 'ltr',
|
||||
|
||||
carousel: 'Vrtuljak',
|
||||
clearEntry: 'Očisti unos',
|
||||
close: 'Zatvori',
|
||||
copied: 'Kopirano',
|
||||
copy: 'Kopiraj',
|
||||
currentValue: 'Trenutna vrijednost',
|
||||
error: 'Greška',
|
||||
goToSlide: (slide, count) => `Idi na slajd ${slide} od ${count}`,
|
||||
hidePassword: 'Sakrij lozinku',
|
||||
loading: 'Učitavanje',
|
||||
nextSlide: 'Sljedeći slajd',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'Nije odabrana nijedna opcija';
|
||||
if (num === 1) return '1 opcija je odabrana';
|
||||
return `${num} odabranih opcija`;
|
||||
},
|
||||
previousSlide: 'Prethodni slajd',
|
||||
progress: 'Napredak',
|
||||
remove: 'Makni',
|
||||
resize: 'Promijeni veličinu',
|
||||
scrollToEnd: 'Skrolaj do kraja',
|
||||
scrollToStart: 'Skrolaj na početak',
|
||||
selectAColorFromTheScreen: 'Odaberi boju sa ekrana',
|
||||
showPassword: 'Pokaži lozinku',
|
||||
slideNum: slide => `Slajd ${slide}`,
|
||||
toggleColorFormat: 'Zamijeni format boje'
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
export default translation;
|
|
@ -0,0 +1,39 @@
|
|||
import { registerTranslation } from '@shoelace-style/localize';
|
||||
import type { Translation } from '../utilities/localize.js';
|
||||
|
||||
const translation: Translation = {
|
||||
$code: 'it',
|
||||
$name: 'Italian',
|
||||
$dir: 'ltr',
|
||||
|
||||
carousel: 'Carosello',
|
||||
clearEntry: 'Cancella inserimento',
|
||||
close: 'Chiudi',
|
||||
copied: 'Copiato',
|
||||
copy: 'Copia',
|
||||
currentValue: 'Valore attuale',
|
||||
error: 'Errore',
|
||||
goToSlide: (slide, count) => `Vai alla diapositiva ${slide} di ${count}`,
|
||||
hidePassword: 'Nascondi password',
|
||||
loading: 'In caricamento',
|
||||
nextSlide: 'Prossima diapositiva',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return 'Nessuna opzione selezionata';
|
||||
if (num === 1) return '1 opzione selezionata';
|
||||
return `${num} opzioni selezionate`;
|
||||
},
|
||||
previousSlide: 'Diapositiva precedente',
|
||||
progress: 'Avanzamento',
|
||||
remove: 'Rimuovi',
|
||||
resize: 'Ridimensiona',
|
||||
scrollToEnd: 'Scorri alla fine',
|
||||
scrollToStart: "Scorri all'inizio",
|
||||
selectAColorFromTheScreen: 'Seleziona un colore dalla schermo',
|
||||
showPassword: 'Mostra password',
|
||||
slideNum: slide => `Diapositiva ${slide}`,
|
||||
toggleColorFormat: 'Cambia formato colore'
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
export default translation;
|
|
@ -0,0 +1,39 @@
|
|||
import { registerTranslation } from '../utilities/localize.js';
|
||||
import type { Translation } from '../utilities/localize.js';
|
||||
|
||||
const translation: Translation = {
|
||||
$code: 'zh-cn',
|
||||
$name: '简体中文',
|
||||
$dir: 'ltr',
|
||||
|
||||
carousel: '跑马灯',
|
||||
clearEntry: '清空',
|
||||
close: '关闭',
|
||||
copied: '已复制',
|
||||
copy: '复制',
|
||||
currentValue: '当前值',
|
||||
error: '错误',
|
||||
goToSlide: (slide, count) => `转到第 ${slide} 张幻灯片,共 ${count} 张`,
|
||||
hidePassword: '隐藏密码',
|
||||
loading: '加载中',
|
||||
nextSlide: '下一张幻灯片',
|
||||
numOptionsSelected: num => {
|
||||
if (num === 0) return '未选择任何项目';
|
||||
if (num === 1) return '已选择 1 个项目';
|
||||
return `${num} 选择项目`;
|
||||
},
|
||||
previousSlide: '上一张幻灯片',
|
||||
progress: '进度',
|
||||
remove: '删除',
|
||||
resize: '调整大小',
|
||||
scrollToEnd: '滚动至页尾',
|
||||
scrollToStart: '滚动至页首',
|
||||
selectAColorFromTheScreen: '从屏幕中选择一种颜色',
|
||||
showPassword: '显示密码',
|
||||
slideNum: slide => `幻灯片 ${slide}`,
|
||||
toggleColorFormat: '切换颜色模式'
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
export default translation;
|
|
@ -1,9 +1,17 @@
|
|||
import '../translations/en.js';
|
||||
import { LocalizeController as DefaultLocalizationController } from '@shoelace-style/localize'; // Register English as the default/fallback language
|
||||
import { LocalizeController as DefaultLocalizationController, registerTranslation } from '@shoelace-style/localize';
|
||||
import en from '../translations/en.js'; // Register English as the default/fallback language
|
||||
import type { Translation as DefaultTranslation } from '@shoelace-style/localize';
|
||||
|
||||
// Extend the controller and apply our own translation interface for better typings
|
||||
export class LocalizeController extends DefaultLocalizationController<Translation> {}
|
||||
export class LocalizeController extends DefaultLocalizationController<Translation> {
|
||||
// Technicallly '../translations/en.js' is supposed to work via side-effects. However, by some mystery sometimes the
|
||||
// translations don't get bundled as expected resulting in `no translation found` errors.
|
||||
// This is basically some extra assurance that our translations get registered prior to our localizer connecting in a component
|
||||
// and we don't rely on implicit import ordering.
|
||||
static {
|
||||
registerTranslation(en);
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions from the localize lib so we have one central place to import them from
|
||||
export { registerTranslation } from '@shoelace-style/localize';
|
||||
|
|
|
@ -6,7 +6,9 @@ export default {
|
|||
rootDir: '.',
|
||||
files: 'src/**/*.test.ts', // "default" group
|
||||
concurrentBrowsers: 3,
|
||||
nodeResolve: true,
|
||||
nodeResolve: {
|
||||
exportConditions: ['production', 'default']
|
||||
},
|
||||
testFramework: {
|
||||
config: {
|
||||
timeout: 3000,
|
||||
|
@ -21,7 +23,9 @@ export default {
|
|||
],
|
||||
browsers: [
|
||||
playwrightLauncher({ product: 'chromium' }),
|
||||
playwrightLauncher({ product: 'firefox' }),
|
||||
// Firefox started failing randomly so we're temporarily disabling it here. This could be a rogue test, not really
|
||||
// sure what's happening.
|
||||
// playwrightLauncher({ product: 'firefox' }),
|
||||
playwrightLauncher({ product: 'webkit' })
|
||||
],
|
||||
testRunnerHtml: testFramework => `
|
||||
|
|
Ładowanie…
Reference in New Issue