mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-19 07:29:14 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ec60af62 | ||
|
|
dcbcc4c050 | ||
|
|
0eb3375bb9 | ||
|
|
c26a8810c8 | ||
|
|
872227e345 | ||
|
|
f22c529eab | ||
|
|
3430b33c3e | ||
|
|
1bc2a6ef76 | ||
|
|
5a94f5bf5b | ||
|
|
4277377189 | ||
|
|
f0efb9253c | ||
|
|
cfd28f2608 | ||
|
|
a3844fe074 | ||
|
|
65e90f12f4 | ||
|
|
4335289d6a | ||
|
|
86cc721e03 | ||
|
|
4a28825ea7 | ||
|
|
19cf823da5 | ||
|
|
737b55d78d | ||
|
|
8493131db5 | ||
|
|
0d86c2af37 | ||
|
|
b260a4dc29 | ||
|
|
1f1024f4ca | ||
|
|
9e92d92684 | ||
|
|
527bf79973 | ||
|
|
b281c5bbc1 | ||
|
|
f03de8925b | ||
|
|
776ab2c715 | ||
|
|
df967b7e84 | ||
|
|
a539058253 | ||
|
|
af70d88153 | ||
|
|
8dcffe270f | ||
|
|
c958f2e50a | ||
|
|
cedcd65c72 | ||
|
|
12f62075ad | ||
|
|
b8695b70a9 | ||
|
|
a4e371618a | ||
|
|
039ab175c3 | ||
|
|
7549e50fe4 | ||
|
|
3c2cda699e | ||
|
|
8685ddd049 | ||
|
|
c47ad40802 | ||
|
|
ef1f129b22 | ||
|
|
6bb508ef14 | ||
|
|
3596c8144d | ||
|
|
20903bb638 | ||
|
|
f45fb6848f | ||
|
|
400f9b76d5 | ||
|
|
38a9e98d9b | ||
|
|
e8fe783fb4 | ||
|
|
223ef32b70 | ||
|
|
0793a219a2 | ||
|
|
3bb92c095f |
2
.github/workflows/node.js.yml
vendored
2
.github/workflows/node.js.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x, 18.x]
|
||||
node-version: [18.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from 'fs';
|
||||
import { generateCustomData } from 'cem-plugin-vs-code-custom-data-generator';
|
||||
import commandLineArgs from 'command-line-args';
|
||||
import { parse } from 'comment-parser';
|
||||
import { pascalCase } from 'pascal-case';
|
||||
import commandLineArgs from 'command-line-args';
|
||||
import fs from 'fs';
|
||||
|
||||
const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
|
||||
const { name, description, version, author, homepage, license } = packageData;
|
||||
|
||||
@@ -635,6 +635,19 @@ If you want to change the icons Shoelace uses internally, you can register an ic
|
||||
|
||||
<!-- Supporting scripts and styles for the search utility -->
|
||||
<script>
|
||||
function wrapWithTooltip(item) {
|
||||
const tooltip = document.createElement('sl-tooltip');
|
||||
tooltip.content = item.getAttribute('data-name');
|
||||
|
||||
// Close open tooltips
|
||||
document.querySelectorAll('.icon-list sl-tooltip[open]').forEach(tooltip => tooltip.hide());
|
||||
|
||||
// Wrap it with a tooltip and trick it into showing up
|
||||
item.parentNode.insertBefore(tooltip, item);
|
||||
tooltip.appendChild(item);
|
||||
requestAnimationFrame(() => tooltip.dispatchEvent(new MouseEvent('mouseover')));
|
||||
}
|
||||
|
||||
fetch('/dist/assets/icons/icons.json')
|
||||
.then(res => res.json())
|
||||
.then(icons => {
|
||||
@@ -658,19 +671,23 @@ If you want to change the icons Shoelace uses internally, you can register an ic
|
||||
<use xlink:href="/assets/icons/sprite.svg#${i.name}"></use>
|
||||
</svg>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
|
||||
const tooltip = document.createElement('sl-tooltip');
|
||||
tooltip.content = i.name;
|
||||
|
||||
tooltip.appendChild(item);
|
||||
list.appendChild(tooltip);
|
||||
// Wrap it with a tooltip the first time the mouse lands on it. We do this instead of baking them into the DOM
|
||||
// to improve this page's performance. See: https://github.com/shoelace-style/shoelace/issues/1122
|
||||
item.addEventListener('mouseover', () => wrapWithTooltip(item), { once: true });
|
||||
|
||||
// Copy on click
|
||||
item.addEventListener('click', () => {
|
||||
const tooltip = item.closest('sl-tooltip');
|
||||
copyInput.value = i.name;
|
||||
copyInput.select();
|
||||
document.execCommand('copy');
|
||||
tooltip.content = 'Copied!';
|
||||
setTimeout(() => tooltip.content = i.name, 1000);
|
||||
|
||||
if (tooltip) {
|
||||
tooltip.content = 'Copied!';
|
||||
setTimeout(() => tooltip.content = i.name, 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ Use [CSS parts](#css-parts) to customize the way form controls are drawn. This e
|
||||
|
||||
<style>
|
||||
.label-on-left {
|
||||
--label-width: 60px;
|
||||
--label-width: 3.75rem;
|
||||
--gap-width: 1rem;
|
||||
}
|
||||
|
||||
@@ -267,8 +267,7 @@ Use [CSS parts](#css-parts) to customize the way form controls are drawn. This e
|
||||
}
|
||||
|
||||
.label-on-left::part(form-control-help-text) {
|
||||
grid-column: span 2;
|
||||
padding-left: calc(var(--label-width) + var(--gap-width));
|
||||
grid-column-start: 2;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
@@ -295,13 +295,15 @@ This example demonstrates custom validation styles using `data-user-invalid` and
|
||||
required
|
||||
></sl-input>
|
||||
|
||||
<sl-select label="Favorite Animal" help-text="Select the best option." clearable required>
|
||||
<sl-select name="animal" label="Favorite Animal" help-text="Select the best option." clearable required>
|
||||
<sl-option value="birds">Birds</sl-option>
|
||||
<sl-option value="cats">Cats</sl-option>
|
||||
<sl-option value="dogs">Dogs</sl-option>
|
||||
<sl-option value="other">Other</sl-option>
|
||||
</sl-select>
|
||||
|
||||
<sl-checkbox value="accept" required>Accept terms and conditions</sl-checkbox>
|
||||
|
||||
<sl-button type="submit" variant="primary">Submit</sl-button>
|
||||
<sl-button type="reset" variant="default">Reset</sl-button>
|
||||
</form>
|
||||
@@ -316,46 +318,172 @@ This example demonstrates custom validation styles using `data-user-invalid` and
|
||||
|
||||
<style>
|
||||
.validity-styles sl-input,
|
||||
.validity-styles sl-select {
|
||||
.validity-styles sl-select,
|
||||
.validity-styles sl-checkbox {
|
||||
display: block;
|
||||
margin-bottom: var(--sl-spacing-medium);
|
||||
}
|
||||
|
||||
/* user invalid styles */
|
||||
.validity-styles sl-input[data-user-invalid]::part(base),
|
||||
.validity-styles sl-select[data-user-invalid]::part(combobox) {
|
||||
.validity-styles sl-select[data-user-invalid]::part(combobox),
|
||||
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
}
|
||||
|
||||
.validity-styles [data-user-invalid]::part(form-control-label),
|
||||
.validity-styles [data-user-invalid]::part(form-control-help-text) {
|
||||
.validity-styles [data-user-invalid]::part(form-control-help-text),
|
||||
.validity-styles sl-checkbox[data-user-invalid]::part(label) {
|
||||
color: var(--sl-color-danger-700);
|
||||
}
|
||||
|
||||
.validity-styles sl-checkbox[data-user-invalid]::part(control) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.validity-styles sl-input:focus-within[data-user-invalid]::part(base),
|
||||
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox) {
|
||||
.validity-styles sl-select:focus-within[data-user-invalid]::part(combobox),
|
||||
.validity-styles sl-checkbox:focus-within[data-user-invalid]::part(control) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
|
||||
}
|
||||
|
||||
/* User valid styles */
|
||||
.validity-styles sl-input[data-user-valid]::part(base),
|
||||
.validity-styles sl-select[data-user-valid]::part(combobox) {
|
||||
.validity-styles sl-select[data-user-valid]::part(combobox),
|
||||
.validity-styles sl-checkbox[data-user-valid]::part(control) {
|
||||
border-color: var(--sl-color-success-600);
|
||||
}
|
||||
|
||||
.validity-styles [data-user-valid]::part(form-control-label),
|
||||
.validity-styles [data-user-valid]::part(form-control-help-text) {
|
||||
.validity-styles [data-user-valid]::part(form-control-help-text),
|
||||
.validity-styles sl-checkbox[data-user-valid]::part(label) {
|
||||
color: var(--sl-color-success-700);
|
||||
}
|
||||
|
||||
.validity-styles sl-checkbox[data-user-valid]::part(control) {
|
||||
background-color: var(--sl-color-success-600);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.validity-styles sl-input:focus-within[data-user-valid]::part(base),
|
||||
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox) {
|
||||
.validity-styles sl-select:focus-within[data-user-valid]::part(combobox),
|
||||
.validity-styles sl-checkbox:focus-within[data-user-valid]::part(control) {
|
||||
border-color: var(--sl-color-success-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Inline Form Validation
|
||||
|
||||
By default, Shoelace form controls use the browser's tooltip-style error messages. No mechanism is provided to show errors inline, as there are too many opinions on how that would work when combined with native form controls and other custom elements. You can, however, implement your own solution using the following technique.
|
||||
|
||||
To disable the browser's error messages, you need to cancel the `sl-invalid` event. Then you can apply your own inline validation errors. This example demonstrates a primitive way to do this.
|
||||
|
||||
```html preview
|
||||
<form class="inline-validation">
|
||||
<sl-input
|
||||
name="name"
|
||||
label="Name"
|
||||
help-text="What would you like people to call you?"
|
||||
autocomplete="off"
|
||||
required
|
||||
></sl-input>
|
||||
|
||||
<div id="name-error" aria-live="polite" hidden></div>
|
||||
|
||||
<sl-button type="submit" variant="primary">Submit</sl-button>
|
||||
<sl-button type="reset" variant="default">Reset</sl-button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const form = document.querySelector('.inline-validation');
|
||||
const nameError = document.querySelector('#name-error');
|
||||
|
||||
// A form control is invalid
|
||||
form.addEventListener(
|
||||
'sl-invalid',
|
||||
event => {
|
||||
// Suppress the browser's constraint validation message
|
||||
event.preventDefault();
|
||||
|
||||
nameError.textContent = `Error: ${event.target.validationMessage}`;
|
||||
nameError.hidden = false;
|
||||
|
||||
event.target.focus();
|
||||
},
|
||||
{ capture: true } // you must use capture since sl-invalid doesn't bubble!
|
||||
);
|
||||
|
||||
// Handle form submit
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
nameError.hidden = true;
|
||||
nameError.textContent = '';
|
||||
setTimeout(() => alert('All fields are valid'), 50);
|
||||
});
|
||||
|
||||
// Handle form reset
|
||||
form.addEventListener('reset', event => {
|
||||
nameError.hidden = true;
|
||||
nameError.textContent = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#name-error {
|
||||
font-size: var(--sl-input-help-text-font-size-medium);
|
||||
color: var(--sl-color-danger-700);
|
||||
}
|
||||
|
||||
#name-error ~ sl-button {
|
||||
margin-top: var(--sl-spacing-medium);
|
||||
}
|
||||
|
||||
.inline-validation sl-input {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* user invalid styles */
|
||||
.inline-validation sl-input[data-user-invalid]::part(base) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
}
|
||||
|
||||
.inline-validation [data-user-invalid]::part(form-control-label),
|
||||
.inline-validation [data-user-invalid]::part(form-control-help-text) {
|
||||
color: var(--sl-color-danger-700);
|
||||
}
|
||||
|
||||
.inline-validation sl-input:focus-within[data-user-invalid]::part(base) {
|
||||
border-color: var(--sl-color-danger-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
|
||||
}
|
||||
|
||||
/* User valid styles */
|
||||
.inline-validation sl-input[data-user-valid]::part(base) {
|
||||
border-color: var(--sl-color-success-600);
|
||||
}
|
||||
|
||||
.inline-validation [data-user-valid]::part(form-control-label),
|
||||
.inline-validation [data-user-valid]::part(form-control-help-text) {
|
||||
color: var(--sl-color-success-700);
|
||||
}
|
||||
|
||||
.inline-validation sl-checkbox[data-user-valid]::part(control) {
|
||||
background-color: var(--sl-color-success-600);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.inline-validation sl-input:focus-within[data-user-valid]::part(base) {
|
||||
border-color: var(--sl-color-success-600);
|
||||
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-success-300);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
!> This example is meant to demonstrate the concept of providing your own error messages inline. It is not intended to scale to more complex forms. Users who want this functionality are encouraged to build a more appropriate validation solution using the techniques shown below. Depending on how you implement this feature, custom error messages may affect the accessibility of your form controls.
|
||||
|
||||
## Getting Associated Form Controls
|
||||
|
||||
At this time, using [`HTMLFormElement.elements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) will not return Shoelace form controls because the browser is unaware of their status as custom element form controls. Fortunately, Shoelace provides an `elements()` function that does something very similar. However, instead of returning an [`HTMLFormControlsCollection`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection), it returns an array of HTML and Shoelace form controls in the order they appear in the DOM.
|
||||
|
||||
@@ -6,12 +6,35 @@ 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).
|
||||
|
||||
## 2.1.0
|
||||
|
||||
- Added the `sl-focus` and `sl-blur` events to `<sl-color-picker>`
|
||||
- Added the `focus()` and `blur()` methods to `<sl-color-picker>`
|
||||
- Added the `sl-invalid` event to all form controls to enable custom validation logic [#1167](https://github.com/shoelace-style/shoelace/pull/1167)
|
||||
- Added `validity` and `validationMessage` properties to all form controls [#1167](https://github.com/shoelace-style/shoelace/pull/1167)
|
||||
- Added the `rel` attribute to `<sl-button>` to allow users to create button links that point to specific targets [#1200](https://github.com/shoelace-style/shoelace/issues/1200)
|
||||
- Fixed a bug in `<sl-animated-image>` where the play and pause buttons were transposed [#1147](https://github.com/shoelace-style/shoelace/issues/1147)
|
||||
- Fixed a bug that prevented `web-types.json` from being generated [#1154](https://github.com/shoelace-style/shoelace/discussions/1154)
|
||||
- Fixed a bug in `<sl-color-picker>` that prevented `sl-change` and `sl-input` from emitting when using the eye dropper [#1157](https://github.com/shoelace-style/shoelace/issues/1157)
|
||||
- Fixed a bug in `<sl-dropdown>` that prevented keyboard users from selecting menu items when using the keyboard [#1165](https://github.com/shoelace-style/shoelace/issues/1165)
|
||||
- Fixed a bug in the template for `<sl-select>` that caused the `form-control-help-text` part to not be in the same location as other form controls [#1178](https://github.com/shoelace-style/shoelace/issues/1178)
|
||||
- Fixed a bug in `<sl-checkbox>` and `<sl-switch>` that caused the browser to scroll incorrectly when focusing on a control in a container with overflow [#1169](https://github.com/shoelace-style/shoelace/issues/1169)
|
||||
- Fixed a bug in `<sl-menu-item>` that caused the `click` event to be emitted when the item was disabled [#1113](https://github.com/shoelace-style/shoelace/issues/1113)
|
||||
- Fixed a bug in form controls that erroneously prevented validation states from being set when `novalidate` was used on the containing form [#1164](https://github.com/shoelace-style/shoelace/issues/1164)
|
||||
- Fixed a bug in `<sl-checkbox>` that caused the required asterisk to appear before the label in Chrome
|
||||
- Fixed a bug that prevented large form control labels from having the correct font size [#1195](https://github.com/shoelace-style/shoelace/pull/1195)
|
||||
- Improved the behavior of `<sl-dropdown>` in Safari so keyboard interaction works the same as in other browsers [#1177](https://github.com/shoelace-style/shoelace/issues/1177)
|
||||
- Improved the [icons](/components/icon) page so it's not as sluggish in Safari [#1122](https://github.com/shoelace-style/shoelace/issues/1122)
|
||||
- Improved the accessibility of `<sl-switch>` when used in forced-colors / Windows High Contrast mode [#1114](https://github.com/shoelace-style/shoelace/issues/1114)
|
||||
- Improved user interaction heuristics for all form controls [#1175](https://github.com/shoelace-style/shoelace/issues/1175)
|
||||
|
||||
## 2.0.0
|
||||
|
||||
This is the first stable release of Shoelace 2, meaning breaking changes to the API will no longer be accepted for this version. Development of Shoelace 2.0 started in January 2020. The first beta was released on [July 15, 2020](https://github.com/shoelace-style/shoelace/releases/tag/v2.0.0-beta.1). Since then, Shoelace has grown quite a bit! Here are some stats from the project as of January 24, 2023:
|
||||
|
||||
- 55 components have been built
|
||||
- [Over 2,500 commits](https://github.com/shoelace-style/shoelace/commits/next) have been made to the project
|
||||
- [88 beta versions](https://github.com/shoelace-style/shoelace/tags) have been released
|
||||
- [85 people](https://github.com/shoelace-style/shoelace/graphs/contributors) have contributed to the project
|
||||
- [669 issues](https://github.com/shoelace-style/shoelace/issues?q=is%3Aissue+is%3Aclosed) have been filed on GitHub
|
||||
- [274 pull requests](https://github.com/shoelace-style/shoelace/pulls) have been opened
|
||||
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@shoelace-style/shoelace",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@shoelace-style/shoelace",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^3.5.0",
|
||||
@@ -30,7 +30,7 @@
|
||||
"@web/test-runner-playwright": "^0.9.0",
|
||||
"bootstrap-icons": "^1.10.3",
|
||||
"browser-sync": "^2.27.11",
|
||||
"cem-plugin-vs-code-custom-data-generator": "^1.3.2",
|
||||
"cem-plugin-vs-code-custom-data-generator": "^1.4.1",
|
||||
"chalk": "^5.2.0",
|
||||
"command-line-args": "^5.2.1",
|
||||
"comment-parser": "^1.3.1",
|
||||
@@ -4005,9 +4005,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cem-plugin-vs-code-custom-data-generator": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/cem-plugin-vs-code-custom-data-generator/-/cem-plugin-vs-code-custom-data-generator-1.3.2.tgz",
|
||||
"integrity": "sha512-1ytpSc3KhS/c1IZ5G03FXJlaCuJ+WZ653w9SXqhABhbpoiQmzioa1Ds0UGHC1vIGT43yoyLMriX+YTg2ZXiuwg==",
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/cem-plugin-vs-code-custom-data-generator/-/cem-plugin-vs-code-custom-data-generator-1.4.1.tgz",
|
||||
"integrity": "sha512-mulzg6I2wJVNKCM9ml4ttxTnGK25kHHdkhX979vbrKwSIIplFnPOgGa0Sj14pQWnfDwbGr6pSbLgBmi4nVHFxA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"prettier": "^2.7.1"
|
||||
@@ -18545,9 +18545,9 @@
|
||||
}
|
||||
},
|
||||
"cem-plugin-vs-code-custom-data-generator": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/cem-plugin-vs-code-custom-data-generator/-/cem-plugin-vs-code-custom-data-generator-1.3.2.tgz",
|
||||
"integrity": "sha512-1ytpSc3KhS/c1IZ5G03FXJlaCuJ+WZ653w9SXqhABhbpoiQmzioa1Ds0UGHC1vIGT43yoyLMriX+YTg2ZXiuwg==",
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/cem-plugin-vs-code-custom-data-generator/-/cem-plugin-vs-code-custom-data-generator-1.4.1.tgz",
|
||||
"integrity": "sha512-mulzg6I2wJVNKCM9ml4ttxTnGK25kHHdkhX979vbrKwSIIplFnPOgGa0Sj14pQWnfDwbGr6pSbLgBmi4nVHFxA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"prettier": "^2.7.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@shoelace-style/shoelace",
|
||||
"description": "A forward-thinking library of web components.",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"homepage": "https://github.com/shoelace-style/shoelace",
|
||||
"author": "Cory LaViska",
|
||||
"license": "MIT",
|
||||
@@ -83,7 +83,7 @@
|
||||
"@web/test-runner-playwright": "^0.9.0",
|
||||
"bootstrap-icons": "^1.10.3",
|
||||
"browser-sync": "^2.27.11",
|
||||
"cem-plugin-vs-code-custom-data-generator": "^1.3.2",
|
||||
"cem-plugin-vs-code-custom-data-generator": "^1.4.1",
|
||||
"chalk": "^5.2.0",
|
||||
"command-line-args": "^5.2.1",
|
||||
"comment-parser": "^1.3.1",
|
||||
|
||||
@@ -64,6 +64,7 @@ const jsonataExprString = `{
|
||||
|
||||
// Run the conversion
|
||||
const expression = jsonata(jsonataExprString);
|
||||
const result = await expression.evaluate(metadata);
|
||||
|
||||
console.log('Generating web types');
|
||||
fs.writeFileSync(path.join(outdir, 'web-types.json'), JSON.stringify(expression.evaluate(metadata), null, 2), 'utf8');
|
||||
fs.writeFileSync(path.join(outdir, 'web-types.json'), JSON.stringify(result, null, 2), 'utf8');
|
||||
|
||||
@@ -1,91 +1,305 @@
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { clickOnElement, moveMouseOnElement } from '../../internal/test';
|
||||
import { queryByTestId } from '../../internal/test/data-testid-helpers';
|
||||
import { resetMouse } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlAlert from './alert';
|
||||
import type SlIconButton from '../icon-button/icon-button';
|
||||
|
||||
const getAlertContainer = (alert: SlAlert): HTMLElement => {
|
||||
return alert.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
|
||||
};
|
||||
|
||||
const expectAlertToBeVisible = (alert: SlAlert): void => {
|
||||
const alertContainer = getAlertContainer(alert);
|
||||
const style = window.getComputedStyle(alertContainer);
|
||||
expect(style.display).not.to.equal('none');
|
||||
expect(style.visibility).not.to.equal('hidden');
|
||||
expect(style.visibility).not.to.equal('collapse');
|
||||
};
|
||||
|
||||
const expectAlertToBeInvisible = (alert: SlAlert): void => {
|
||||
const alertContainer = getAlertContainer(alert);
|
||||
const style = window.getComputedStyle(alertContainer);
|
||||
expect(style.display, 'alert should be invisible').to.equal('none');
|
||||
};
|
||||
|
||||
const expectHideAndAfterHideToBeEmittedInCorrectOrder = async (alert: SlAlert, action: () => void | Promise<void>) => {
|
||||
const hidePromise = oneEvent(alert, 'sl-hide');
|
||||
const afterHidePromise = oneEvent(alert, 'sl-after-hide');
|
||||
let afterHideHappened = false;
|
||||
oneEvent(alert, 'sl-after-hide').then(() => (afterHideHappened = true));
|
||||
|
||||
action();
|
||||
|
||||
await hidePromise;
|
||||
expect(afterHideHappened).to.be.false;
|
||||
|
||||
await afterHidePromise;
|
||||
expectAlertToBeInvisible(alert);
|
||||
};
|
||||
|
||||
const expectShowAndAfterShowToBeEmittedInCorrectOrder = async (alert: SlAlert, action: () => void | Promise<void>) => {
|
||||
const showPromise = oneEvent(alert, 'sl-show');
|
||||
const afterShowPromise = oneEvent(alert, 'sl-after-show');
|
||||
let afterShowHappened = false;
|
||||
oneEvent(alert, 'sl-after-show').then(() => (afterShowHappened = true));
|
||||
|
||||
action();
|
||||
|
||||
await showPromise;
|
||||
expect(afterShowHappened).to.be.false;
|
||||
|
||||
await afterShowPromise;
|
||||
expectAlertToBeVisible(alert);
|
||||
};
|
||||
|
||||
const getCloseButton = (alert: SlAlert): SlIconButton | null | undefined =>
|
||||
alert.shadowRoot?.querySelector<SlIconButton>('[part="close-button"]');
|
||||
|
||||
describe('<sl-alert>', () => {
|
||||
it('should be visible with the open attribute', async () => {
|
||||
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
let clock: sinon.SinonFakeTimers | null = null;
|
||||
|
||||
expect(base.hidden).to.be.false;
|
||||
afterEach(async () => {
|
||||
clock?.restore();
|
||||
await resetMouse();
|
||||
});
|
||||
|
||||
it('should not be visible without the open attribute', async () => {
|
||||
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
it('renders', async () => {
|
||||
const alert = await fixture<SlAlert>(html`<sl-alert open>I am an alert</sl-alert>`);
|
||||
|
||||
expect(base.hidden).to.be.true;
|
||||
expectAlertToBeVisible(alert);
|
||||
});
|
||||
|
||||
it('should emit sl-show and sl-after-show when calling show()', async () => {
|
||||
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
const showHandler = sinon.spy();
|
||||
const afterShowHandler = sinon.spy();
|
||||
it('is accessible', async () => {
|
||||
const alert = await fixture<SlAlert>(html`<sl-alert open>I am an alert</sl-alert>`);
|
||||
|
||||
el.addEventListener('sl-show', showHandler);
|
||||
el.addEventListener('sl-after-show', afterShowHandler);
|
||||
el.show();
|
||||
|
||||
await waitUntil(() => showHandler.calledOnce);
|
||||
await waitUntil(() => afterShowHandler.calledOnce);
|
||||
|
||||
expect(showHandler).to.have.been.calledOnce;
|
||||
expect(afterShowHandler).to.have.been.calledOnce;
|
||||
expect(base.hidden).to.be.false;
|
||||
await expect(alert).to.be.accessible();
|
||||
});
|
||||
|
||||
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
|
||||
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
const hideHandler = sinon.spy();
|
||||
const afterHideHandler = sinon.spy();
|
||||
describe('alert visibility', () => {
|
||||
it('should be visible with the open attribute', async () => {
|
||||
const alert = await fixture<SlAlert>(html`<sl-alert open>I am an alert</sl-alert>`);
|
||||
|
||||
el.addEventListener('sl-hide', hideHandler);
|
||||
el.addEventListener('sl-after-hide', afterHideHandler);
|
||||
el.hide();
|
||||
expectAlertToBeVisible(alert);
|
||||
});
|
||||
|
||||
await waitUntil(() => hideHandler.calledOnce);
|
||||
await waitUntil(() => afterHideHandler.calledOnce);
|
||||
it('should not be visible without the open attribute', async () => {
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert>`);
|
||||
|
||||
expect(hideHandler).to.have.been.calledOnce;
|
||||
expect(afterHideHandler).to.have.been.calledOnce;
|
||||
expect(base.hidden).to.be.true;
|
||||
expectAlertToBeInvisible(alert);
|
||||
});
|
||||
|
||||
it('should emit sl-show and sl-after-show when calling show()', async () => {
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert>`);
|
||||
|
||||
expectAlertToBeInvisible(alert);
|
||||
|
||||
await expectShowAndAfterShowToBeEmittedInCorrectOrder(alert, () => alert.show());
|
||||
});
|
||||
|
||||
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert>`);
|
||||
|
||||
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => alert.hide());
|
||||
});
|
||||
|
||||
it('should emit sl-show and sl-after-show when setting open = true', async () => {
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
|
||||
|
||||
await expectShowAndAfterShowToBeEmittedInCorrectOrder(alert, () => {
|
||||
alert.open = true;
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
|
||||
|
||||
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
|
||||
alert.open = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit sl-show and sl-after-show when setting open = true', async () => {
|
||||
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
const showHandler = sinon.spy();
|
||||
const afterShowHandler = sinon.spy();
|
||||
describe('close button', () => {
|
||||
it('shows a close button if the alert has the closable attribute', () => async () => {
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert open closable>I am an alert</sl-alert> `);
|
||||
const closeButton = getCloseButton(alert);
|
||||
|
||||
el.addEventListener('sl-show', showHandler);
|
||||
el.addEventListener('sl-after-show', afterShowHandler);
|
||||
el.open = true;
|
||||
expect(closeButton).to.be.visible;
|
||||
});
|
||||
|
||||
await waitUntil(() => showHandler.calledOnce);
|
||||
await waitUntil(() => afterShowHandler.calledOnce);
|
||||
it('clicking the close button closes the alert', () => async () => {
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert open closable>I am an alert</sl-alert> `);
|
||||
const closeButton = getCloseButton(alert);
|
||||
|
||||
expect(showHandler).to.have.been.calledOnce;
|
||||
expect(afterShowHandler).to.have.been.calledOnce;
|
||||
expect(base.hidden).to.be.false;
|
||||
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
|
||||
clickOnElement(closeButton!);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
|
||||
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
|
||||
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
|
||||
const hideHandler = sinon.spy();
|
||||
const afterHideHandler = sinon.spy();
|
||||
describe('toast', () => {
|
||||
const getToastStack = (): HTMLDivElement | null => document.querySelector<HTMLDivElement>('.sl-toast-stack');
|
||||
|
||||
el.addEventListener('sl-hide', hideHandler);
|
||||
el.addEventListener('sl-after-hide', afterHideHandler);
|
||||
el.open = false;
|
||||
const closeRemainingAlerts = async (): Promise<void> => {
|
||||
const toastStack = getToastStack();
|
||||
if (toastStack?.children) {
|
||||
for (const element of toastStack.children) {
|
||||
await (element as SlAlert).hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await waitUntil(() => hideHandler.calledOnce);
|
||||
await waitUntil(() => afterHideHandler.calledOnce);
|
||||
beforeEach(async () => {
|
||||
await closeRemainingAlerts();
|
||||
});
|
||||
|
||||
expect(hideHandler).to.have.been.calledOnce;
|
||||
expect(afterHideHandler).to.have.been.calledOnce;
|
||||
expect(base.hidden).to.be.true;
|
||||
it('can be rendered as a toast', async () => {
|
||||
const alert = await fixture<SlAlert>(html`<sl-alert>I am an alert</sl-alert>`);
|
||||
|
||||
expectShowAndAfterShowToBeEmittedInCorrectOrder(alert, () => alert.toast());
|
||||
const toastStack = getToastStack();
|
||||
expect(toastStack).to.be.visible;
|
||||
expect(toastStack?.firstChild).to.be.equal(alert);
|
||||
});
|
||||
|
||||
it('resolves only after being closed', async () => {
|
||||
const alert = await fixture<SlAlert>(html`<sl-alert closable>I am an alert</sl-alert>`);
|
||||
|
||||
const afterShowEvent = oneEvent(alert, 'sl-after-show');
|
||||
let toastPromiseResolved = false;
|
||||
alert.toast().then(() => (toastPromiseResolved = true));
|
||||
|
||||
await afterShowEvent;
|
||||
expect(toastPromiseResolved).to.be.false;
|
||||
|
||||
const closePromise = oneEvent(alert, 'sl-after-hide');
|
||||
const closeButton = getCloseButton(alert);
|
||||
clickOnElement(closeButton!);
|
||||
|
||||
await closePromise;
|
||||
await aTimeout(0);
|
||||
|
||||
expect(toastPromiseResolved).to.be.true;
|
||||
});
|
||||
|
||||
const expectToastStack = () => {
|
||||
const toastStack = getToastStack();
|
||||
expect(toastStack).not.to.be.null;
|
||||
};
|
||||
|
||||
const expectNoToastStack = () => {
|
||||
const toastStack = getToastStack();
|
||||
expect(toastStack).to.be.null;
|
||||
};
|
||||
|
||||
const openToast = async (alert: SlAlert): Promise<void> => {
|
||||
const openPromise = oneEvent(alert, 'sl-after-show');
|
||||
alert.toast();
|
||||
await openPromise;
|
||||
};
|
||||
|
||||
const closeToast = async (alert: SlAlert): Promise<void> => {
|
||||
const closePromise = oneEvent(alert, 'sl-after-hide');
|
||||
const closeButton = getCloseButton(alert);
|
||||
await clickOnElement(closeButton!);
|
||||
await closePromise;
|
||||
await aTimeout(0);
|
||||
};
|
||||
|
||||
it('deletes the toast stack after the last alert is done', async () => {
|
||||
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>`);
|
||||
|
||||
const alert1 = queryByTestId<SlAlert>(container, 'alert1');
|
||||
const alert2 = queryByTestId<SlAlert>(container, 'alert2');
|
||||
|
||||
await openToast(alert1!);
|
||||
|
||||
expectToastStack();
|
||||
|
||||
await openToast(alert2!);
|
||||
|
||||
expectToastStack();
|
||||
|
||||
await closeToast(alert1!);
|
||||
|
||||
expectToastStack();
|
||||
|
||||
await closeToast(alert2!);
|
||||
|
||||
expectNoToastStack();
|
||||
});
|
||||
});
|
||||
|
||||
describe('timer controlled closing', () => {
|
||||
it('closes after a predefined amount of time', async () => {
|
||||
clock = sinon.useFakeTimers();
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert open duration="3000">I am an alert</sl-alert>`);
|
||||
|
||||
expectAlertToBeVisible(alert);
|
||||
|
||||
clock.tick(2999);
|
||||
|
||||
expectAlertToBeVisible(alert);
|
||||
|
||||
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
|
||||
clock?.tick(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the closing timer after mouse-over', async () => {
|
||||
clock = sinon.useFakeTimers();
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert open duration="3000">I am an alert</sl-alert>`);
|
||||
|
||||
expectAlertToBeVisible(alert);
|
||||
|
||||
clock.tick(1000);
|
||||
|
||||
await moveMouseOnElement(alert);
|
||||
|
||||
clock.tick(2999);
|
||||
|
||||
expectAlertToBeVisible(alert);
|
||||
|
||||
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
|
||||
clock?.tick(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the closing timer after opening', async () => {
|
||||
clock = sinon.useFakeTimers();
|
||||
const alert = await fixture<SlAlert>(html` <sl-alert duration="3000">I am an alert</sl-alert>`);
|
||||
|
||||
expectAlertToBeInvisible(alert);
|
||||
|
||||
clock.tick(1000);
|
||||
|
||||
const afterShowPromise = oneEvent(alert, 'sl-after-show');
|
||||
alert.show();
|
||||
await afterShowPromise;
|
||||
|
||||
clock.tick(2999);
|
||||
|
||||
await expectHideAndAfterHideToBeEmittedInCorrectOrder(alert, () => {
|
||||
clock?.tick(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert variants', () => {
|
||||
const variants = ['primary', 'success', 'neutral', 'warning', 'danger'];
|
||||
|
||||
variants.forEach(variant => {
|
||||
it(`adapts to the variant: ${variant}`, async () => {
|
||||
const alert = await fixture<SlAlert>(html`<sl-alert variant="${variant}" open>I am an alert</sl-alert>`);
|
||||
|
||||
const alertContainer = getAlertContainer(alert);
|
||||
expect(alertContainer).to.have.class(`alert--${variant}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,8 +50,8 @@ export default css`
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:host([play]) slot[name='pause-icon'],
|
||||
:host(:not([play])) slot[name='play-icon'] {
|
||||
:host([play]) slot[name='play-icon'],
|
||||
:host(:not([play])) slot[name='pause-icon'] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -48,7 +48,7 @@ export default css`
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* When disabled, prevent mouse events from bubbling up */
|
||||
/* When disabled, prevent mouse events from bubbling up from children */
|
||||
.button--disabled * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import sinon from 'sinon';
|
||||
import type SlButton from './button';
|
||||
|
||||
@@ -116,6 +117,30 @@ describe('<sl-button>', () => {
|
||||
expect(el.shadowRoot!.querySelector('a')).to.exist;
|
||||
expect(el.shadowRoot!.querySelector('button')).not.to.exist;
|
||||
});
|
||||
|
||||
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 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 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 link = el.shadowRoot!.querySelector('a')!;
|
||||
expect(link?.getAttribute('rel')).to.equal('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting a form', () => {
|
||||
@@ -234,4 +259,31 @@ describe('<sl-button>', () => {
|
||||
expect(clickHandler).to.have.been.calledOnce;
|
||||
});
|
||||
});
|
||||
|
||||
runFormControlBaseTests({
|
||||
tagName: 'sl-button',
|
||||
variantName: 'type="button"',
|
||||
|
||||
init: (control: SlButton) => {
|
||||
control.type = 'button';
|
||||
}
|
||||
});
|
||||
|
||||
runFormControlBaseTests({
|
||||
tagName: 'sl-button',
|
||||
variantName: 'type="submit"',
|
||||
|
||||
init: (control: SlButton) => {
|
||||
control.type = 'submit';
|
||||
}
|
||||
});
|
||||
|
||||
runFormControlBaseTests({
|
||||
tagName: 'sl-button',
|
||||
variantName: 'href="xyz"',
|
||||
|
||||
init: (control: SlButton) => {
|
||||
control.href = 'some-url';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import '../icon/icon';
|
||||
import '../spinner/spinner';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { FormControlController } from '../../internal/form';
|
||||
import { FormControlController, validValidityState } from '../../internal/form';
|
||||
import { HasSlotController } from '../../internal/slot';
|
||||
import { html, literal } from 'lit/static-html.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
@@ -24,6 +24,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
|
||||
*
|
||||
* @event sl-blur - Emitted when the button loses focus.
|
||||
* @event sl-focus - Emitted when the button gains focus.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @slot - The button's label.
|
||||
* @slot prefix - A presentational prefix icon or similar element.
|
||||
@@ -41,7 +42,7 @@ 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 it set we need to query
|
||||
// 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;
|
||||
@@ -51,7 +52,8 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
|
||||
// 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);
|
||||
@@ -114,6 +116,14 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
/** Tells the browser where to open the link. Only used when `href` is present. */
|
||||
@property() target: '_blank' | '_parent' | '_self' | '_top';
|
||||
|
||||
/**
|
||||
* When using `href`, this attribute will map to the underlying link's `rel` attribute. Unlike regular links, the
|
||||
* default is `noreferrer noopener` to prevent security exploits. However, if you're using `target` to point to a
|
||||
* specific tab/window, this will prevent that from working correctly. You can remove or change the default value by
|
||||
* setting the attribute to an empty string or a value of your choice, respectively.
|
||||
*/
|
||||
@property() rel = 'noreferrer noopener';
|
||||
|
||||
/** Tells the browser to download the linked file as this filename. Only used when `href` is present. */
|
||||
@property() download?: string;
|
||||
|
||||
@@ -139,6 +149,35 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
/** Used to override the form owner's `target` attribute. */
|
||||
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validity;
|
||||
}
|
||||
|
||||
return validValidityState;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).validationMessage;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleHostClick = this.handleHostClick.bind(this);
|
||||
this.addEventListener('click', this.handleHostClick);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('click', this.handleHostClick);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
if (this.isButton()) {
|
||||
this.formControlController.updateValidity();
|
||||
@@ -155,13 +194,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
if (this.disabled || this.loading) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
if (this.type === 'submit') {
|
||||
this.formControlController.submit(this);
|
||||
}
|
||||
@@ -171,6 +204,19 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
}
|
||||
}
|
||||
|
||||
private handleHostClick(event: MouseEvent) {
|
||||
// Prevent the click event from being emitted when the button is disabled or loading
|
||||
if (this.disabled || this.loading) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private isButton() {
|
||||
return this.href ? false : true;
|
||||
}
|
||||
@@ -202,7 +248,7 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
this.button.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
if (this.isButton()) {
|
||||
return (this.button as HTMLButtonElement).checkValidity();
|
||||
@@ -270,12 +316,13 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
|
||||
href=${ifDefined(isLink ? this.href : undefined)}
|
||||
target=${ifDefined(isLink ? this.target : undefined)}
|
||||
download=${ifDefined(isLink ? this.download : undefined)}
|
||||
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
|
||||
rel=${ifDefined(isLink ? this.rel : undefined)}
|
||||
role=${ifDefined(isLink ? undefined : 'button')}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
tabindex=${this.disabled ? '-1' : '0'}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@invalid=${this.isButton() ? this.handleInvalid : null}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
<slot name="prefix" part="prefix" class="button__prefix"></slot>
|
||||
|
||||
@@ -9,6 +9,7 @@ export default css`
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: top;
|
||||
font-family: var(--sl-input-font-family);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlCheckbox from './checkbox';
|
||||
@@ -94,6 +95,21 @@ describe('<sl-checkbox>', () => {
|
||||
await el.updateComplete;
|
||||
});
|
||||
|
||||
it('should hide the native input with the correct positioning to scroll correctly when contained in an overflow', async () => {
|
||||
//
|
||||
// See: https://github.com/shoelace-style/shoelace/issues/1169
|
||||
//
|
||||
const el = await fixture<SlCheckbox>(html` <sl-checkbox></sl-checkbox> `);
|
||||
const label = el.shadowRoot!.querySelector('.checkbox')!;
|
||||
const input = el.shadowRoot!.querySelector('.checkbox__input')!;
|
||||
|
||||
const labelPosition = getComputedStyle(label).position;
|
||||
const inputPosition = getComputedStyle(input).position;
|
||||
|
||||
expect(labelPosition).to.equal('relative');
|
||||
expect(inputPosition).to.equal('absolute');
|
||||
});
|
||||
|
||||
describe('when submitting a form', () => {
|
||||
it('should submit the correct value when a value is provided', async () => {
|
||||
const form = await fixture<HTMLFormElement>(html`
|
||||
@@ -184,6 +200,18 @@ describe('<sl-checkbox>', () => {
|
||||
|
||||
expect(formData.get('a')).to.equal('1');
|
||||
});
|
||||
|
||||
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-checkbox required></sl-checkbox></form> `);
|
||||
const checkbox = el.querySelector<SlCheckbox>('sl-checkbox')!;
|
||||
|
||||
expect(checkbox.hasAttribute('data-required')).to.be.true;
|
||||
expect(checkbox.hasAttribute('data-optional')).to.be.false;
|
||||
expect(checkbox.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(checkbox.hasAttribute('data-valid')).to.be.false;
|
||||
expect(checkbox.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(checkbox.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when resetting a form', () => {
|
||||
@@ -281,5 +309,7 @@ describe('<sl-checkbox>', () => {
|
||||
|
||||
expect(indeterminateIcon).to.be.null;
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-checkbox');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
|
||||
* @event sl-change - Emitted when the checked state changes.
|
||||
* @event sl-focus - Emitted when the checkbox gains focus.
|
||||
* @event sl-input - Emitted when the checkbox receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart control - The square container that wraps the checkbox's checked state.
|
||||
@@ -85,6 +86,16 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
/** Makes the checkbox a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
@@ -104,6 +115,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
@@ -137,12 +153,12 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show a validation message. Returns true when valid and false when invalid. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows a validation message if the control is invalid. */
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
return this.input.reportValidity();
|
||||
}
|
||||
@@ -157,6 +173,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
}
|
||||
|
||||
render() {
|
||||
//
|
||||
// NOTE: we use a <div> around the label slot because of this Chrome bug.
|
||||
//
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1413733
|
||||
//
|
||||
return html`
|
||||
<label
|
||||
part="base"
|
||||
@@ -184,6 +205,7 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
/>
|
||||
@@ -209,7 +231,9 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
|
||||
: ''}
|
||||
</span>
|
||||
|
||||
<slot part="label" class="checkbox__label"></slot>
|
||||
<div part="label" class="checkbox__label">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { serialize } from '../../utilities/form';
|
||||
import sinon from 'sinon';
|
||||
@@ -324,6 +325,101 @@ describe('<sl-color-picker>', () => {
|
||||
expect(previewColor).to.equal('#ff000050');
|
||||
});
|
||||
|
||||
it('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>
|
||||
<button type="button">Click me</button>
|
||||
</div>
|
||||
`);
|
||||
const colorPicker = el.querySelector('sl-color-picker')!;
|
||||
const trigger = colorPicker.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
|
||||
const button = el.querySelector('button')!;
|
||||
const focusHandler = sinon.spy();
|
||||
const blurHandler = sinon.spy();
|
||||
|
||||
colorPicker.addEventListener('sl-focus', focusHandler);
|
||||
colorPicker.addEventListener('sl-blur', blurHandler);
|
||||
|
||||
await clickOnElement(trigger);
|
||||
await colorPicker.updateComplete;
|
||||
expect(focusHandler).to.have.been.calledOnce;
|
||||
|
||||
await clickOnElement(button);
|
||||
await colorPicker.updateComplete;
|
||||
expect(blurHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('should emit sl-focus when rendered inline and focused', async () => {
|
||||
const el = await fixture<SlColorPicker>(html`
|
||||
<div>
|
||||
<sl-color-picker inline></sl-color-picker>
|
||||
<button type="button">Click me</button>
|
||||
</div>
|
||||
`);
|
||||
const colorPicker = el.querySelector('sl-color-picker')!;
|
||||
const button = el.querySelector('button')!;
|
||||
const focusHandler = sinon.spy();
|
||||
const blurHandler = sinon.spy();
|
||||
|
||||
colorPicker.addEventListener('sl-focus', focusHandler);
|
||||
colorPicker.addEventListener('sl-blur', blurHandler);
|
||||
|
||||
await clickOnElement(colorPicker);
|
||||
await colorPicker.updateComplete;
|
||||
expect(focusHandler).to.have.been.calledOnce;
|
||||
|
||||
await clickOnElement(button);
|
||||
await colorPicker.updateComplete;
|
||||
expect(blurHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('should focus and blur when calling focus() and blur() and rendered as a dropdown', async () => {
|
||||
const colorPicker = await fixture<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
|
||||
const focusHandler = sinon.spy();
|
||||
const blurHandler = sinon.spy();
|
||||
|
||||
colorPicker.addEventListener('sl-focus', focusHandler);
|
||||
colorPicker.addEventListener('sl-blur', blurHandler);
|
||||
|
||||
// Focus
|
||||
colorPicker.focus();
|
||||
await colorPicker.updateComplete;
|
||||
|
||||
expect(document.activeElement).to.equal(colorPicker);
|
||||
expect(focusHandler).to.have.been.calledOnce;
|
||||
|
||||
// Blur
|
||||
colorPicker.blur();
|
||||
await colorPicker.updateComplete;
|
||||
|
||||
expect(document.activeElement).to.equal(document.body);
|
||||
expect(blurHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('should focus and blur when calling focus() and blur() and rendered inline', async () => {
|
||||
const colorPicker = await fixture<SlColorPicker>(html` <sl-color-picker inline></sl-color-picker> `);
|
||||
const focusHandler = sinon.spy();
|
||||
const blurHandler = sinon.spy();
|
||||
|
||||
colorPicker.addEventListener('sl-focus', focusHandler);
|
||||
colorPicker.addEventListener('sl-blur', blurHandler);
|
||||
|
||||
// Focus
|
||||
colorPicker.focus();
|
||||
await colorPicker.updateComplete;
|
||||
|
||||
expect(document.activeElement).to.equal(colorPicker);
|
||||
expect(focusHandler).to.have.been.calledOnce;
|
||||
|
||||
// Blur
|
||||
colorPicker.blur();
|
||||
await colorPicker.updateComplete;
|
||||
|
||||
expect(document.activeElement).to.equal(document.body);
|
||||
expect(blurHandler).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
describe('when submitting a form', () => {
|
||||
it('should serialize its name and value with FormData', async () => {
|
||||
const form = await fixture<HTMLFormElement>(html`
|
||||
@@ -397,20 +493,20 @@ describe('<sl-color-picker>', () => {
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
});
|
||||
|
||||
it('should be invalid when required and empty', async () => {
|
||||
const el = await fixture<SlColorPicker>(html` <sl-input required></sl-input> `);
|
||||
it.skip('should be invalid when required and empty', async () => {
|
||||
const el = await fixture<SlColorPicker>(html` <sl-color-picker required></sl-color-picker> `);
|
||||
expect(el.checkValidity()).to.be.false;
|
||||
});
|
||||
|
||||
it('should be invalid when required and disabled is removed', async () => {
|
||||
const el = await fixture<SlColorPicker>(html` <sl-input disabled required></sl-input> `);
|
||||
it.skip('should be invalid when required and disabled is removed', async () => {
|
||||
const el = await fixture<SlColorPicker>(html` <sl-color-picker disabled required></sl-color-picker> `);
|
||||
el.disabled = false;
|
||||
await el.updateComplete;
|
||||
expect(el.checkValidity()).to.be.false;
|
||||
});
|
||||
|
||||
it('should receive the correct validation attributes ("states") when valid', async () => {
|
||||
const el = await fixture<SlColorPicker>(html` <sl-input required value="a"></sl-input> `);
|
||||
it.skip('should receive the correct validation attributes ("states") when valid', async () => {
|
||||
const el = await fixture<SlColorPicker>(html` <sl-color-picker required value="a"></sl-color-picker> `);
|
||||
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
expect(el.hasAttribute('data-required')).to.be.true;
|
||||
@@ -420,17 +516,18 @@ describe('<sl-color-picker>', () => {
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.false;
|
||||
|
||||
el.focus();
|
||||
await sendKeys({ press: 'b' });
|
||||
await el.updateComplete;
|
||||
// // TODO simulate user interaction
|
||||
// el.focus();
|
||||
// await sendKeys({ press: 'b' });
|
||||
// await el.updateComplete;
|
||||
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.true;
|
||||
// expect(el.checkValidity()).to.be.true;
|
||||
// expect(el.hasAttribute('data-user-invalid')).to.be.false;
|
||||
// expect(el.hasAttribute('data-user-valid')).to.be.true;
|
||||
});
|
||||
|
||||
it('should receive the correct validation attributes ("states") when invalid', async () => {
|
||||
const el = await fixture<SlColorPicker>(html` <sl-input required></sl-input> `);
|
||||
it.skip('should receive the correct validation attributes ("states") when invalid', async () => {
|
||||
const el = await fixture<SlColorPicker>(html` <sl-color-picker required></sl-color-picker> `);
|
||||
|
||||
expect(el.hasAttribute('data-required')).to.be.true;
|
||||
expect(el.hasAttribute('data-optional')).to.be.false;
|
||||
@@ -439,13 +536,16 @@ describe('<sl-color-picker>', () => {
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.false;
|
||||
|
||||
el.focus();
|
||||
await sendKeys({ press: 'a' });
|
||||
await sendKeys({ press: 'Backspace' });
|
||||
await el.updateComplete;
|
||||
// // TODO simulate user interaction
|
||||
// el.focus();
|
||||
// await sendKeys({ press: 'a' });
|
||||
// await sendKeys({ press: 'Backspace' });
|
||||
// await el.updateComplete;
|
||||
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.false;
|
||||
// expect(el.hasAttribute('data-user-invalid')).to.be.true;
|
||||
// expect(el.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-color-picker');
|
||||
});
|
||||
|
||||
@@ -49,8 +49,11 @@ declare const EyeDropper: EyeDropperConstructor;
|
||||
*
|
||||
* @slot label - The color picker's form label. Alternatively, you can use the `label` attribute.
|
||||
*
|
||||
* @event sl-change Emitted when the color picker's value changes.
|
||||
* @event sl-input Emitted when the color picker receives input.
|
||||
* @event sl-blur - Emitted when the color picker loses focus.
|
||||
* @event sl-change - Emitted when the color picker's value changes.
|
||||
* @event sl-focus - Emitted when the color picker receives focus.
|
||||
* @event sl-input - Emitted when the color picker receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart trigger - The color picker's dropdown trigger.
|
||||
@@ -94,10 +97,13 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
private isSafeValue = false;
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@query('[part~="base"]') base: HTMLElement;
|
||||
@query('[part~="input"]') input: SlInput;
|
||||
@query('[part~="preview"]') previewButton: HTMLButtonElement;
|
||||
@query('.color-dropdown') dropdown: SlDropdown;
|
||||
@query('[part~="preview"]') previewButton: HTMLButtonElement;
|
||||
@query('[part~="trigger"]') trigger: HTMLButtonElement;
|
||||
|
||||
@state() private hasFocus = false;
|
||||
@state() private isDraggingGridHandle = false;
|
||||
@state() private isEmpty = false;
|
||||
@state() private inputValue = '';
|
||||
@@ -169,6 +175,39 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
*/
|
||||
@property({ reflect: true }) form = '';
|
||||
|
||||
/** Makes the color picker a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleFocusIn = this.handleFocusIn.bind(this);
|
||||
this.handleFocusOut = this.handleFocusOut.bind(this);
|
||||
this.addEventListener('focusin', this.handleFocusIn);
|
||||
this.addEventListener('focusout', this.handleFocusOut);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('focusin', this.handleFocusIn);
|
||||
this.removeEventListener('focusout', this.handleFocusOut);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.input.updateComplete.then(() => {
|
||||
this.formControlController.updateValidity();
|
||||
});
|
||||
}
|
||||
|
||||
private handleCopy() {
|
||||
this.input.select();
|
||||
document.execCommand('copy');
|
||||
@@ -181,6 +220,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
});
|
||||
}
|
||||
|
||||
private handleFocusIn() {
|
||||
this.hasFocus = true;
|
||||
this.emit('sl-focus');
|
||||
}
|
||||
|
||||
private handleFocusOut() {
|
||||
this.hasFocus = false;
|
||||
this.emit('sl-blur');
|
||||
}
|
||||
|
||||
private handleFormatToggle() {
|
||||
const formats = ['hex', 'rgb', 'hsl', 'hsv'];
|
||||
const nextIndex = (formats.indexOf(this.format) + 1) % formats.length;
|
||||
@@ -389,6 +438,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
|
||||
private handleInputInput(event: CustomEvent) {
|
||||
this.formControlController.updateValidity();
|
||||
|
||||
// Prevent the <sl-input>'s sl-input event from bubbling up
|
||||
event.stopPropagation();
|
||||
}
|
||||
@@ -413,6 +464,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
}
|
||||
|
||||
private handleInputInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleTouchMove(event: TouchEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -563,7 +619,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
|
||||
eyeDropper
|
||||
.open()
|
||||
.then(colorSelectionResult => this.setColor(colorSelectionResult.sRGBHex))
|
||||
.then(colorSelectionResult => {
|
||||
const oldValue = this.value;
|
||||
|
||||
this.setColor(colorSelectionResult.sRGBHex);
|
||||
|
||||
if (this.value !== oldValue) {
|
||||
this.emit('sl-change');
|
||||
this.emit('sl-input');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The user canceled, do nothing
|
||||
});
|
||||
@@ -592,6 +657,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
return color.toHex8String();
|
||||
}
|
||||
|
||||
// Prevents nested components from leaking events
|
||||
private stopNestedEventPropagation(event: CustomEvent) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
@watch('format', { waitUntilFirstUpdate: true })
|
||||
handleFormatChange() {
|
||||
this.syncValues();
|
||||
@@ -629,6 +699,32 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets focus on the color picker. */
|
||||
focus(options?: FocusOptions) {
|
||||
if (this.inline) {
|
||||
this.base.focus(options);
|
||||
} else {
|
||||
this.trigger.focus(options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes focus from the color picker. */
|
||||
blur() {
|
||||
const elementToBlur = this.inline ? this.base : this.trigger;
|
||||
|
||||
if (this.hasFocus) {
|
||||
// We don't know which element in the color picker has focus, so we'll move it to the trigger or base (inline) and
|
||||
// blur that instead. This results in document.activeElement becoming the <body>. This doesn't cause another focus
|
||||
// event because we're using focusin and something inside the color picker already has focus.
|
||||
elementToBlur.focus({ preventScroll: true });
|
||||
elementToBlur.blur();
|
||||
}
|
||||
|
||||
if (this.dropdown?.open) {
|
||||
this.dropdown.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the current value as a string in the specified format. */
|
||||
getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hsv' | 'hsva' = 'hex') {
|
||||
const currentColor = this.parseColor(
|
||||
@@ -661,18 +757,24 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity() {
|
||||
if (!this.inline && !this.checkValidity()) {
|
||||
if (!this.inline && !this.validity.valid) {
|
||||
// If the input is inline and invalid, show the dropdown so the browser can focus on it
|
||||
this.dropdown.show();
|
||||
this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true });
|
||||
return this.checkValidity();
|
||||
|
||||
if (!this.disabled) {
|
||||
// By standards we have to emit a `sl-invalid` event here synchronously.
|
||||
this.formControlController.emitInvalidEvent();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.input.reportValidity();
|
||||
@@ -697,7 +799,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
class=${classMap({
|
||||
'color-picker': true,
|
||||
'color-picker--inline': this.inline,
|
||||
'color-picker--disabled': this.disabled
|
||||
'color-picker--disabled': this.disabled,
|
||||
'color-picker--focused': this.hasFocus
|
||||
})}
|
||||
aria-disabled=${this.disabled ? 'true' : 'false'}
|
||||
aria-labelledby="label"
|
||||
@@ -821,11 +924,15 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
value=${this.isEmpty ? '' : this.inputValue}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
aria-label=${this.localize.term('currentValue')}
|
||||
@keydown=${this.handleInputKeyDown}
|
||||
@sl-change=${this.handleInputChange}
|
||||
@sl-input=${this.handleInputInput}
|
||||
@sl-invalid=${this.handleInputInvalid}
|
||||
@sl-blur=${this.stopNestedEventPropagation}
|
||||
@sl-focus=${this.stopNestedEventPropagation}
|
||||
></sl-input>
|
||||
|
||||
<sl-button-group>
|
||||
@@ -842,6 +949,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
caret:format-button__caret
|
||||
"
|
||||
@click=${this.handleFormatToggle}
|
||||
@sl-blur=${this.stopNestedEventPropagation}
|
||||
@sl-focus=${this.stopNestedEventPropagation}
|
||||
>
|
||||
${this.setLetterCase(this.format)}
|
||||
</sl-button>
|
||||
@@ -859,6 +968,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
caret:eye-dropper-button__caret
|
||||
"
|
||||
@click=${this.handleEyeDropper}
|
||||
@sl-blur=${this.stopNestedEventPropagation}
|
||||
@sl-focus=${this.stopNestedEventPropagation}
|
||||
>
|
||||
<sl-icon
|
||||
library="system"
|
||||
@@ -932,6 +1043,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
|
||||
'color-dropdown__trigger--medium': this.size === 'medium',
|
||||
'color-dropdown__trigger--large': this.size === 'large',
|
||||
'color-dropdown__trigger--empty': this.isEmpty,
|
||||
'color-dropdown__trigger--focused': this.hasFocus,
|
||||
'color-picker__transparent-bg': true
|
||||
})}
|
||||
style=${styleMap({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { sendKeys, sendMouse } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
@@ -179,6 +180,27 @@ describe('<sl-dropdown>', () => {
|
||||
expect(el.open).to.be.true;
|
||||
});
|
||||
|
||||
it('should navigate to first focusable item on arrow navigation', async () => {
|
||||
const el = await fixture<SlDropdown>(html`
|
||||
<sl-dropdown>
|
||||
<sl-button slot="trigger" caret>Toggle</sl-button>
|
||||
<sl-menu>
|
||||
<sl-menu-label>Top Label</sl-menu-label>
|
||||
<sl-menu-item>Item 1</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-dropdown>
|
||||
`);
|
||||
const trigger = el.querySelector('sl-button')!;
|
||||
const item = el.querySelector('sl-menu-item')!;
|
||||
|
||||
await clickOnElement(trigger);
|
||||
await trigger.updateComplete;
|
||||
await sendKeys({ press: 'ArrowDown' });
|
||||
await el.updateComplete;
|
||||
|
||||
expect(document.activeElement).to.equal(item);
|
||||
});
|
||||
|
||||
it('should close on escape key', async () => {
|
||||
const el = await fixture<SlDropdown>(html`
|
||||
<sl-dropdown open>
|
||||
@@ -233,6 +255,30 @@ describe('<sl-dropdown>', () => {
|
||||
expect(el.open).to.be.true;
|
||||
});
|
||||
|
||||
it('should focus on menu items when clicking the trigger and arrowing through options', async () => {
|
||||
const el = await fixture<SlDropdown>(html`
|
||||
<sl-dropdown>
|
||||
<sl-button slot="trigger" caret>Toggle</sl-button>
|
||||
<sl-menu>
|
||||
<sl-menu-item>Item 1</sl-menu-item>
|
||||
<sl-menu-item>Item 2</sl-menu-item>
|
||||
<sl-menu-item>Item 3</sl-menu-item>
|
||||
</sl-menu>
|
||||
</sl-dropdown>
|
||||
`);
|
||||
const trigger = el.querySelector('sl-button')!;
|
||||
const secondMenuItem = el.querySelectorAll('sl-menu-item')[1];
|
||||
|
||||
await clickOnElement(trigger);
|
||||
await trigger.updateComplete;
|
||||
await sendKeys({ press: 'ArrowDown' });
|
||||
await el.updateComplete;
|
||||
await sendKeys({ press: 'ArrowDown' });
|
||||
await el.updateComplete;
|
||||
|
||||
expect(document.activeElement).to.equal(secondMenuItem);
|
||||
});
|
||||
|
||||
it('should open on enter key when no menu exists', async () => {
|
||||
const el = await fixture<SlDropdown>(html`
|
||||
<sl-dropdown>
|
||||
|
||||
@@ -155,6 +155,14 @@ export default class SlDropdown extends ShoelaceElement {
|
||||
}
|
||||
|
||||
handleDocumentKeyDown(event: KeyboardEvent) {
|
||||
// Close when escape or tab is pressed
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.stopPropagation();
|
||||
this.focusOnTrigger();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle tabbing
|
||||
if (event.key === 'Tab') {
|
||||
// Tabbing within an open menu should close the dropdown and refocus the trigger
|
||||
@@ -213,18 +221,11 @@ export default class SlDropdown extends ShoelaceElement {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
this.focusOnTrigger();
|
||||
}
|
||||
}
|
||||
|
||||
handleTriggerKeyDown(event: KeyboardEvent) {
|
||||
// Close when escape or tab is pressed
|
||||
if (event.key === 'Escape' && this.open) {
|
||||
event.stopPropagation();
|
||||
this.focusOnTrigger();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
|
||||
// key again to hide the menu in case they don't want to make a selection.
|
||||
if ([' ', 'Enter'].includes(event.key)) {
|
||||
@@ -236,7 +237,7 @@ export default class SlDropdown extends ShoelaceElement {
|
||||
const menu = this.getMenu();
|
||||
|
||||
if (menu) {
|
||||
const menuItems = menu.defaultSlot.assignedElements({ flatten: true }) as SlMenuItem[];
|
||||
const menuItems = menu.getAllItems();
|
||||
const firstMenuItem = menuItems[0];
|
||||
const lastMenuItem = menuItems[menuItems.length - 1];
|
||||
|
||||
@@ -253,7 +254,7 @@ export default class SlDropdown extends ShoelaceElement {
|
||||
|
||||
if (menuItems.length > 0) {
|
||||
// Focus on the first/last menu item after showing
|
||||
requestAnimationFrame(() => {
|
||||
this.updateComplete.then(() => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'Home') {
|
||||
menu.setCurrentItem(firstMenuItem);
|
||||
firstMenuItem.focus();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
||||
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { getFormControls } from '../../../dist/utilities/form.js';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { serialize } from '../../utilities/form'; // must come from the same module
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands'; // must come from the same module
|
||||
import { serialize } from '../../utilities/form';
|
||||
import sinon from 'sinon';
|
||||
import type SlInput from './input';
|
||||
|
||||
@@ -130,6 +131,8 @@ describe('<sl-input>', () => {
|
||||
await el.updateComplete;
|
||||
await sendKeys({ press: 'b' });
|
||||
await el.updateComplete;
|
||||
el.blur();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.false;
|
||||
@@ -151,10 +154,24 @@ describe('<sl-input>', () => {
|
||||
await sendKeys({ press: 'a' });
|
||||
await sendKeys({ press: 'Backspace' });
|
||||
await el.updateComplete;
|
||||
el.blur();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
|
||||
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-input required></sl-input></form> `);
|
||||
const input = el.querySelector<SlInput>('sl-input')!;
|
||||
|
||||
expect(input.hasAttribute('data-required')).to.be.true;
|
||||
expect(input.hasAttribute('data-optional')).to.be.false;
|
||||
expect(input.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(input.hasAttribute('data-valid')).to.be.false;
|
||||
expect(input.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(input.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting a form', () => {
|
||||
@@ -218,6 +235,8 @@ describe('<sl-input>', () => {
|
||||
input.focus();
|
||||
await sendKeys({ type: 'test' });
|
||||
await input.updateComplete;
|
||||
input.blur();
|
||||
await input.updateComplete;
|
||||
|
||||
expect(input.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(input.hasAttribute('data-user-valid')).to.be.false;
|
||||
@@ -478,4 +497,6 @@ describe('<sl-input>', () => {
|
||||
expect(formControls.map((fc: HTMLInputElement) => fc.value).join('')).to.equal('12345678910'); // eslint-disable-line
|
||||
});
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-input');
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
|
||||
* @event sl-clear - Emitted when the clear button is activated.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
@@ -63,7 +64,9 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
|
||||
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this);
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
|
||||
@@ -225,6 +228,16 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
this.value = input.value;
|
||||
}
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
@@ -260,8 +273,9 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid() {
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -370,7 +384,7 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export default css`
|
||||
color: var(--sl-color-neutral-1000);
|
||||
}
|
||||
|
||||
:host(:focus-visible) .menu-item {
|
||||
:host(:focus) .menu-item {
|
||||
outline: none;
|
||||
background-color: var(--sl-color-primary-600);
|
||||
color: var(--sl-color-neutral-0);
|
||||
@@ -93,7 +93,7 @@ export default css`
|
||||
|
||||
@media (forced-colors: active) {
|
||||
:host(:hover:not([aria-disabled='true'])) .menu-item,
|
||||
:host(:focus-visible) .menu-item {
|
||||
:host(:focus) .menu-item {
|
||||
outline: dashed 1px SelectedItem;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
import type SlMenuItem from './menu-item';
|
||||
|
||||
@@ -26,13 +27,20 @@ describe('<sl-menu-item>', () => {
|
||||
});
|
||||
|
||||
it('should render the correct aria attributes when disabled', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
|
||||
|
||||
el.disabled = true;
|
||||
await aTimeout(100);
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item disabled>Test</sl-menu-item> `);
|
||||
expect(el.getAttribute('aria-disabled')).to.equal('true');
|
||||
});
|
||||
|
||||
it('should not emit the click event when disabled', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item disabled>Test</sl-menu-item> `);
|
||||
const clickHandler = sinon.spy();
|
||||
el.addEventListener('click', clickHandler);
|
||||
await clickOnElement(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(clickHandler).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('should return a text label when calling getTextLabel()', async () => {
|
||||
const el = await fixture<SlMenuItem>(html` <sl-menu-item>Test</sl-menu-item> `);
|
||||
expect(el.getTextLabel()).to.equal('Test');
|
||||
|
||||
@@ -47,6 +47,17 @@ export default class SlMenuItem extends ShoelaceElement {
|
||||
/** Draws the menu item in a disabled state, preventing selection. */
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleHostClick = this.handleHostClick.bind(this);
|
||||
this.addEventListener('click', this.handleHostClick);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('click', this.handleHostClick);
|
||||
}
|
||||
|
||||
private handleDefaultSlotChange() {
|
||||
const textLabel = this.getTextLabel();
|
||||
|
||||
@@ -63,6 +74,14 @@ export default class SlMenuItem extends ShoelaceElement {
|
||||
}
|
||||
}
|
||||
|
||||
private handleHostClick(event: MouseEvent) {
|
||||
// Prevent the click event from being emitted when the button is disabled or loading
|
||||
if (this.disabled) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
@watch('checked')
|
||||
handleCheckedChange() {
|
||||
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
|
||||
|
||||
@@ -29,16 +29,6 @@ export default class SlMenu extends ShoelaceElement {
|
||||
this.setAttribute('role', 'menu');
|
||||
}
|
||||
|
||||
private getAllItems() {
|
||||
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
|
||||
if (el.inert || !this.isMenuItem(el)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) as SlMenuItem[];
|
||||
}
|
||||
|
||||
private handleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const item = target.closest('sl-menu-item');
|
||||
@@ -125,6 +115,16 @@ export default class SlMenu extends ShoelaceElement {
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
|
||||
getAllItems() {
|
||||
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
|
||||
if (el.inert || !this.isMenuItem(el)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}) as SlMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
|
||||
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
|
||||
|
||||
@@ -38,7 +38,7 @@ import type { CSSResultGroup } from 'lit';
|
||||
export default class SlPopup extends ShoelaceElement {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private anchorEl: HTMLElement | null;
|
||||
private anchorEl: Element | null;
|
||||
private cleanup: ReturnType<typeof autoUpdate> | undefined;
|
||||
|
||||
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
|
||||
@@ -223,7 +223,7 @@ export default class SlPopup extends ShoelaceElement {
|
||||
// Locate the anchor by id
|
||||
const root = this.getRootNode() as Document | ShadowRoot;
|
||||
this.anchorEl = root.getElementById(this.anchor);
|
||||
} else if (this.anchor instanceof HTMLElement) {
|
||||
} else if (this.anchor instanceof Element) {
|
||||
// Use the anchor's reference
|
||||
this.anchorEl = this.anchor;
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlRadio from '../radio/radio';
|
||||
@@ -130,6 +131,25 @@ describe('<sl-radio-group>', () => {
|
||||
expect(radioGroup.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(radioGroup.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
|
||||
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html`
|
||||
<form novalidate>
|
||||
<sl-radio-group required>
|
||||
<sl-radio value="1"></sl-radio>
|
||||
<sl-radio value="2"></sl-radio>
|
||||
</sl-radio-group>
|
||||
</form>
|
||||
`);
|
||||
const radioGroup = el.querySelector<SlRadioGroup>('sl-radio-group')!;
|
||||
|
||||
expect(radioGroup.hasAttribute('data-required')).to.be.true;
|
||||
expect(radioGroup.hasAttribute('data-optional')).to.be.false;
|
||||
expect(radioGroup.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(radioGroup.hasAttribute('data-valid')).to.be.false;
|
||||
expect(radioGroup.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(radioGroup.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
it('should show a constraint validation error when setCustomValidity() is called', async () => {
|
||||
@@ -296,4 +316,6 @@ describe('when the value changes', () => {
|
||||
radioGroup.value = '2';
|
||||
await radioGroup.updateComplete;
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-radio-group');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import '../button-group/button-group';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { FormControlController } from '../../internal/form';
|
||||
import {
|
||||
customErrorValidityState,
|
||||
FormControlController,
|
||||
validValidityState,
|
||||
valueMissingValidityState
|
||||
} from '../../internal/form';
|
||||
import { HasSlotController } from '../../internal/slot';
|
||||
import { html } from 'lit';
|
||||
import { watch } from '../../internal/watch';
|
||||
@@ -26,6 +31,7 @@ import type SlRadioButton from '../radio-button/radio-button';
|
||||
*
|
||||
* @event sl-change - Emitted when the radio group's selected value changes.
|
||||
* @event sl-input - Emitted when the radio group receives user input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
@@ -75,6 +81,34 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
|
||||
/** Ensures a child radio is checked before allowing the containing form to submit. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (hasCustomValidityMessage) {
|
||||
return customErrorValidityState;
|
||||
} else if (isRequiredAndEmpty) {
|
||||
return valueMissingValidityState;
|
||||
}
|
||||
|
||||
return validValidityState;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (hasCustomValidityMessage) {
|
||||
return this.customValidityMessage;
|
||||
} else if (isRequiredAndEmpty) {
|
||||
return this.validationInput.validationMessage;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.defaultValue = this.value;
|
||||
@@ -187,10 +221,15 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private updateCheckedRadio() {
|
||||
const radios = this.getAllRadios();
|
||||
radios.forEach(radio => (radio.checked = radio.value === this.value));
|
||||
this.formControlController.setValidity(this.checkValidity());
|
||||
this.formControlController.setValidity(this.validity.valid);
|
||||
}
|
||||
|
||||
@watch('value')
|
||||
@@ -200,12 +239,13 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
const isRequiredAndEmpty = this.required && !this.value;
|
||||
const hasCustomValidityMessage = this.customValidityMessage !== '';
|
||||
|
||||
if (isRequiredAndEmpty || hasCustomValidityMessage) {
|
||||
this.formControlController.emitInvalidEvent();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -222,7 +262,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
|
||||
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity(): boolean {
|
||||
const isValid = this.checkValidity();
|
||||
const isValid = this.validity.valid;
|
||||
|
||||
this.errorMessage = this.customValidityMessage || isValid ? '' : this.validationInput.validationMessage;
|
||||
this.formControlController.setValidity(isValid);
|
||||
@@ -289,6 +329,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
|
||||
?required=${this.required}
|
||||
tabindex="-1"
|
||||
hidden
|
||||
@invalid=${this.handleInvalid}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { serialize } from '../../utilities/form';
|
||||
import sinon from 'sinon';
|
||||
@@ -164,11 +165,26 @@ describe('<sl-range>', () => {
|
||||
|
||||
await clickOnElement(range);
|
||||
await range.updateComplete;
|
||||
range.blur();
|
||||
await range.updateComplete;
|
||||
|
||||
expect(range.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(range.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
|
||||
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-range></sl-range></form> `);
|
||||
const range = el.querySelector<SlRange>('sl-range')!;
|
||||
|
||||
range.setCustomValidity('Invalid value');
|
||||
await range.updateComplete;
|
||||
|
||||
expect(range.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(range.hasAttribute('data-valid')).to.be.false;
|
||||
expect(range.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(range.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
|
||||
it('should be present in form data when using the form attribute and located outside of a <form>', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html`
|
||||
<div>
|
||||
@@ -214,4 +230,6 @@ describe('<sl-range>', () => {
|
||||
expect(input.value).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-range');
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
|
||||
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
@@ -101,6 +102,16 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue = 0;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.resizeObserver = new ResizeObserver(() => this.syncRange());
|
||||
@@ -207,6 +218,11 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
|
||||
}
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
/** Sets focus on the range. */
|
||||
focus(options?: FocusOptions) {
|
||||
this.input.focus(options);
|
||||
@@ -233,7 +249,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
@@ -306,8 +322,9 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
|
||||
.value=${live(this.value.toString())}
|
||||
aria-describedby="help-text"
|
||||
@change=${this.handleChange}
|
||||
@input=${this.handleInput}
|
||||
@focus=${this.handleFocus}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
${this.tooltip !== 'none' && !this.disabled
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { serialize } from '../../utilities/form';
|
||||
import sinon from 'sinon';
|
||||
@@ -263,6 +264,8 @@ describe('<sl-select>', () => {
|
||||
await el.show();
|
||||
await clickOnElement(secondOption);
|
||||
await el.updateComplete;
|
||||
el.blur();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.false;
|
||||
@@ -290,10 +293,32 @@ describe('<sl-select>', () => {
|
||||
await clickOnElement(secondOption);
|
||||
el.value = '';
|
||||
await el.updateComplete;
|
||||
el.blur();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
|
||||
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html`
|
||||
<form novalidate>
|
||||
<sl-select required>
|
||||
<sl-option value="option-1">Option 1</sl-option>
|
||||
<sl-option value="option-2">Option 2</sl-option>
|
||||
<sl-option value="option-3">Option 3</sl-option>
|
||||
</sl-select>
|
||||
</form>
|
||||
`);
|
||||
const select = el.querySelector<SlSelect>('sl-select')!;
|
||||
|
||||
expect(select.hasAttribute('data-required')).to.be.true;
|
||||
expect(select.hasAttribute('data-optional')).to.be.false;
|
||||
expect(select.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(select.hasAttribute('data-valid')).to.be.false;
|
||||
expect(select.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(select.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting a form', () => {
|
||||
@@ -524,4 +549,6 @@ describe('<sl-select>', () => {
|
||||
|
||||
expect(tag.hasAttribute('pill')).to.be.true;
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-select');
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ import type SlPopup from '../popup/popup';
|
||||
* @event sl-after-show - Emitted after the select's menu opens and all animations are complete.
|
||||
* @event sl-hide - Emitted when the select's menu closes.
|
||||
* @event sl-after-hide - Emitted after the select's menu closes and all animations are complete.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
@@ -64,7 +65,9 @@ import type SlPopup from '../popup/popup';
|
||||
export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this);
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private typeToSelectString = '';
|
||||
@@ -160,6 +163,16 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||
/** The select's required attribute. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.valueInput.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.valueInput.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this);
|
||||
@@ -518,6 +531,11 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||
});
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
@watch('disabled', { waitUntilFirstUpdate: true })
|
||||
handleDisabledChange() {
|
||||
// Close the listbox when the control is disabled
|
||||
@@ -601,7 +619,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||
return waitForEvent(this, 'sl-after-hide');
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.valueInput.checkValidity();
|
||||
}
|
||||
@@ -750,6 +768,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
@focus=${() => this.focus()}
|
||||
@invalid=${this.handleInvalid}
|
||||
/>
|
||||
|
||||
${hasClearIcon
|
||||
@@ -788,17 +807,17 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
|
||||
@slotchange=${this.handleDefaultSlotChange}
|
||||
></slot>
|
||||
</sl-popup>
|
||||
|
||||
<slot
|
||||
name="help-text"
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
${this.helpText}
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="help-text"
|
||||
part="form-control-help-text"
|
||||
id="help-text"
|
||||
class="form-control__help-text"
|
||||
aria-hidden=${hasHelpText ? 'false' : 'true'}
|
||||
>
|
||||
${this.helpText}
|
||||
</slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default css`
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: var(--sl-input-font-family);
|
||||
@@ -153,4 +154,11 @@ export default css`
|
||||
content: var(--sl-input-required-content);
|
||||
margin-inline-start: var(--sl-input-required-content-offset);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.switch.switch--checked:not(.switch--disabled) .switch__control:hover .switch__thumb,
|
||||
.switch--checked .switch__control .switch__thumb {
|
||||
background-color: ButtonText;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import sinon from 'sinon';
|
||||
import type SlSwitch from './switch';
|
||||
@@ -113,6 +114,21 @@ describe('<sl-switch>', () => {
|
||||
await el.updateComplete;
|
||||
});
|
||||
|
||||
it('should hide the native input with the correct positioning to scroll correctly when contained in an overflow', async () => {
|
||||
//
|
||||
// See: https://github.com/shoelace-style/shoelace/issues/1169
|
||||
//
|
||||
const el = await fixture<SlSwitch>(html` <sl-switch></sl-switch> `);
|
||||
const label = el.shadowRoot!.querySelector('.switch')!;
|
||||
const input = el.shadowRoot!.querySelector('.switch__input')!;
|
||||
|
||||
const labelPosition = getComputedStyle(label).position;
|
||||
const inputPosition = getComputedStyle(input).position;
|
||||
|
||||
expect(labelPosition).to.equal('relative');
|
||||
expect(inputPosition).to.equal('absolute');
|
||||
});
|
||||
|
||||
describe('when submitting a form', () => {
|
||||
it('should submit the correct value when a value is provided', async () => {
|
||||
const form = await fixture<HTMLFormElement>(html`
|
||||
@@ -202,6 +218,18 @@ describe('<sl-switch>', () => {
|
||||
|
||||
expect(formData.get('a')).to.equal('1');
|
||||
});
|
||||
|
||||
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-switch required></sl-switch></form> `);
|
||||
const slSwitch = el.querySelector<SlSwitch>('sl-switch')!;
|
||||
|
||||
expect(slSwitch.hasAttribute('data-required')).to.be.true;
|
||||
expect(slSwitch.hasAttribute('data-optional')).to.be.false;
|
||||
expect(slSwitch.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(slSwitch.hasAttribute('data-valid')).to.be.false;
|
||||
expect(slSwitch.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(slSwitch.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when resetting a form', () => {
|
||||
@@ -233,4 +261,6 @@ describe('<sl-switch>', () => {
|
||||
expect(switchEl.checked).to.false;
|
||||
});
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-switch');
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
|
||||
* @event sl-change - Emitted when the control's checked state changes.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart base - The component's base wrapper.
|
||||
* @csspart control - The control that houses the switch's thumb.
|
||||
@@ -76,6 +77,16 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
|
||||
/** Makes the switch a required field. */
|
||||
@property({ type: Boolean, reflect: true }) required = false;
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.formControlController.updateValidity();
|
||||
}
|
||||
@@ -89,6 +100,11 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.checked = !this.checked;
|
||||
this.emit('sl-change');
|
||||
@@ -142,7 +158,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
|
||||
this.input.blur();
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
@@ -185,6 +201,7 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
|
||||
aria-checked=${this.checked ? 'true' : 'false'}
|
||||
@click=${this.handleClick}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@blur=${this.handleBlur}
|
||||
@focus=${this.handleFocus}
|
||||
@keydown=${this.handleKeyDown}
|
||||
|
||||
449
src/components/tab-group/tab-group.test.ts
Normal file
449
src/components/tab-group/tab-group.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { aTimeout, elementUpdated, expect, fixture, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { clickOnElement } from '../../internal/test';
|
||||
import { html } from 'lit';
|
||||
import { isElementVisibleFromOverflow } from '../../internal/test/element-visible-overflow';
|
||||
import { queryByTestId } from '../../internal/test/data-testid-helpers';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { waitForScrollingToEnd } from '../../internal/test/wait-for-scrolling';
|
||||
import type { HTMLTemplateResult } from 'lit';
|
||||
import type SlTab from '../tab/tab';
|
||||
import type SlTabGroup from './tab-group';
|
||||
import type SlTabPanel from '../tab-panel/tab-panel';
|
||||
|
||||
interface ClientRectangles {
|
||||
body?: DOMRect;
|
||||
navigation?: DOMRect;
|
||||
}
|
||||
|
||||
interface CustomEventPayload {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const waitForScrollButtonsToBeRendered = async (tabGroup: SlTabGroup): Promise<void> => {
|
||||
await waitUntil(() => {
|
||||
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
|
||||
return scrollButtons?.length === 2;
|
||||
});
|
||||
};
|
||||
|
||||
const getClientRectangles = (tabGroup: SlTabGroup): ClientRectangles => {
|
||||
const shadowRoot = tabGroup.shadowRoot;
|
||||
if (shadowRoot) {
|
||||
const nav = shadowRoot.querySelector<HTMLElement>('[part=nav]');
|
||||
const body = shadowRoot.querySelector<HTMLElement>('[part=body]');
|
||||
return {
|
||||
body: body?.getBoundingClientRect(),
|
||||
navigation: nav?.getBoundingClientRect()
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const expectHeaderToBeVisible = (container: HTMLElement, dataTestId: string): void => {
|
||||
const generalHeader = queryByTestId<SlTab>(container, dataTestId);
|
||||
expect(generalHeader).not.to.be.null;
|
||||
expect(generalHeader).to.be.visible;
|
||||
};
|
||||
|
||||
const expectOnlyOneTabPanelToBeActive = async (container: HTMLElement, dataTestIdOfActiveTab: string) => {
|
||||
await waitUntil(() => {
|
||||
const tabPanels = Array.from(container.getElementsByTagName('sl-tab-panel'));
|
||||
const activeTabPanels = tabPanels.filter((element: SlTabPanel) => element.hasAttribute('active'));
|
||||
return activeTabPanels.length === 1;
|
||||
});
|
||||
const tabPanels = Array.from(container.getElementsByTagName('sl-tab-panel'));
|
||||
const activeTabPanels = tabPanels.filter((element: SlTabPanel) => element.hasAttribute('active'));
|
||||
expect(activeTabPanels).to.have.lengthOf(1);
|
||||
expect(activeTabPanels[0]).to.have.attribute('data-testid', dataTestIdOfActiveTab);
|
||||
};
|
||||
|
||||
const expectPromiseToHaveName = async (showEventPromise: Promise<CustomEvent>, expectedName: string) => {
|
||||
const showEvent = await showEventPromise;
|
||||
expect((showEvent.detail as CustomEventPayload).name).to.equal(expectedName);
|
||||
};
|
||||
|
||||
const waitForHeaderToBeActive = async (container: HTMLElement, headerTestId: string): Promise<SlTab> => {
|
||||
const generalHeader = queryByTestId<SlTab>(container, headerTestId);
|
||||
await waitUntil(() => {
|
||||
return generalHeader?.hasAttribute('active');
|
||||
});
|
||||
if (generalHeader) {
|
||||
return generalHeader;
|
||||
} else {
|
||||
throw new Error(`did not find error with testid=${headerTestId}`);
|
||||
}
|
||||
};
|
||||
|
||||
describe('<sl-tab-group>', () => {
|
||||
it('renders', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general">General</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
expect(tabGroup).to.be.visible;
|
||||
});
|
||||
|
||||
it('is accessible', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general">General</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
await expect(tabGroup).to.be.accessible();
|
||||
});
|
||||
|
||||
it('displays all tabs', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general" data-testid="general-tab-header">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="disabled" disabled data-testid="disabled-tab-header">Disabled</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="disabled">This is a disabled tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
expectHeaderToBeVisible(tabGroup, 'general-tab-header');
|
||||
expectHeaderToBeVisible(tabGroup, 'disabled-tab-header');
|
||||
});
|
||||
|
||||
it('shows the first tab to be active by default', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="custom">Custom</sl-tab>
|
||||
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
await expectOnlyOneTabPanelToBeActive(tabGroup, 'general-tab-content');
|
||||
});
|
||||
|
||||
describe('proper positioning', () => {
|
||||
it('shows the header above the tabs by default', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general">General</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
await aTimeout(0);
|
||||
|
||||
const clientRectangles = getClientRectangles(tabGroup);
|
||||
expect(clientRectangles.body?.top).to.be.greaterThanOrEqual(clientRectangles.navigation?.bottom || -Infinity);
|
||||
});
|
||||
|
||||
it('shows the header below the tabs by setting placement to bottom', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general">General</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
tabGroup.placement = 'bottom';
|
||||
|
||||
await aTimeout(0);
|
||||
|
||||
const clientRectangles = getClientRectangles(tabGroup);
|
||||
expect(clientRectangles.body?.bottom).to.be.lessThanOrEqual(clientRectangles.navigation?.top || +Infinity);
|
||||
});
|
||||
|
||||
it('shows the header left of the tabs by setting placement to start', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general">General</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
tabGroup.placement = 'start';
|
||||
|
||||
await aTimeout(0);
|
||||
|
||||
const clientRectangles = getClientRectangles(tabGroup);
|
||||
expect(clientRectangles.body?.left).to.be.greaterThanOrEqual(clientRectangles.navigation?.right || -Infinity);
|
||||
});
|
||||
|
||||
it('shows the header right of the tabs by setting placement to end', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general">General</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
tabGroup.placement = 'end';
|
||||
|
||||
await aTimeout(0);
|
||||
|
||||
const clientRectangles = getClientRectangles(tabGroup);
|
||||
expect(clientRectangles.body?.right).to.be.lessThanOrEqual(clientRectangles.navigation?.left || -Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling behavior', () => {
|
||||
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> `);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
before(() => {
|
||||
// disabling failing on resize observer ... unfortunately on webkit this is not really specific
|
||||
// https://github.com/WICG/resize-observer/issues/38#issuecomment-422126006
|
||||
// https://stackoverflow.com/a/64197640
|
||||
const errorHandler = window.onerror;
|
||||
window.onerror = (
|
||||
event: string | Event,
|
||||
source?: string | undefined,
|
||||
lineno?: number | undefined,
|
||||
colno?: number | undefined,
|
||||
error?: Error | undefined
|
||||
) => {
|
||||
if ((event as string).includes('ResizeObserver') || event === 'Script error.') {
|
||||
return true;
|
||||
} else if (errorHandler) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return errorHandler(event, source, lineno, colno, error);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it('shows scroll buttons on too many tabs', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(30)} </sl-tab-group>`);
|
||||
|
||||
await waitForScrollButtonsToBeRendered(tabGroup);
|
||||
|
||||
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
|
||||
expect(scrollButtons, 'Both scroll buttons should be shown').to.have.length(2);
|
||||
|
||||
tabGroup.disconnectedCallback();
|
||||
});
|
||||
|
||||
it('does not show scroll buttons on too many tabs if deactivated', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(30)} </sl-tab-group>`);
|
||||
tabGroup.noScrollControls = true;
|
||||
|
||||
await aTimeout(0);
|
||||
|
||||
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
|
||||
expect(scrollButtons).to.have.length(0);
|
||||
});
|
||||
|
||||
it('does not show scroll buttons if all tabs fit on the screen', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(2)} </sl-tab-group>`);
|
||||
|
||||
await aTimeout(0);
|
||||
|
||||
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
|
||||
expect(scrollButtons).to.have.length(0);
|
||||
});
|
||||
|
||||
it('does not show scroll buttons if placement is start', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(50)} </sl-tab-group>`);
|
||||
tabGroup.placement = 'start';
|
||||
|
||||
await aTimeout(0);
|
||||
|
||||
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
|
||||
expect(scrollButtons).to.have.length(0);
|
||||
});
|
||||
|
||||
it('does not show scroll buttons if placement is end', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(50)} </sl-tab-group>`);
|
||||
tabGroup.placement = 'end';
|
||||
|
||||
await aTimeout(0);
|
||||
|
||||
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
|
||||
expect(scrollButtons).to.have.length(0);
|
||||
});
|
||||
|
||||
it('does scroll on scroll button click', async () => {
|
||||
const numberOfElements = 15;
|
||||
const tabGroup = await fixture<SlTabGroup>(
|
||||
html`<sl-tab-group> ${generateTabs(numberOfElements)} </sl-tab-group>`
|
||||
);
|
||||
|
||||
await waitForScrollButtonsToBeRendered(tabGroup);
|
||||
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
|
||||
expect(scrollButtons).to.have.length(2);
|
||||
|
||||
const firstTab = tabGroup.querySelector('[panel="tab-0"]');
|
||||
expect(firstTab).not.to.be.null;
|
||||
const lastTab = tabGroup.querySelector(`[panel="tab-${numberOfElements - 1}"]`);
|
||||
expect(lastTab).not.to.be.null;
|
||||
expect(isElementVisibleFromOverflow(tabGroup, firstTab!)).to.be.true;
|
||||
expect(isElementVisibleFromOverflow(tabGroup, lastTab!)).to.be.false;
|
||||
|
||||
const scrollToRightButton = tabGroup.shadowRoot?.querySelector('sl-icon-button[part*="scroll-button--end"]');
|
||||
expect(scrollToRightButton).not.to.be.null;
|
||||
await clickOnElement(scrollToRightButton!);
|
||||
|
||||
await elementUpdated(tabGroup);
|
||||
await waitForScrollingToEnd(firstTab!);
|
||||
await waitForScrollingToEnd(lastTab!);
|
||||
|
||||
expect(isElementVisibleFromOverflow(tabGroup, firstTab!)).to.be.false;
|
||||
expect(isElementVisibleFromOverflow(tabGroup, lastTab!)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab selection', () => {
|
||||
const expectCustomTabToBeActiveAfter = async (tabGroup: SlTabGroup, action: () => Promise<void>): Promise<void> => {
|
||||
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
|
||||
generalHeader.focus();
|
||||
|
||||
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
|
||||
expect(customHeader).not.to.have.attribute('active');
|
||||
|
||||
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<CustomEvent>;
|
||||
await action();
|
||||
|
||||
expect(customHeader).to.have.attribute('active');
|
||||
await expectPromiseToHaveName(showEventPromise, 'custom');
|
||||
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
|
||||
};
|
||||
|
||||
const expectGeneralTabToBeStillActiveAfter = async (
|
||||
tabGroup: SlTabGroup,
|
||||
action: () => Promise<void>
|
||||
): Promise<void> => {
|
||||
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
|
||||
generalHeader.focus();
|
||||
|
||||
let showEventFired = false;
|
||||
let hideEventFired = false;
|
||||
oneEvent(tabGroup, 'sl-tab-show').then(() => (showEventFired = true));
|
||||
oneEvent(tabGroup, 'sl-tab-hide').then(() => (hideEventFired = true));
|
||||
await action();
|
||||
|
||||
expect(generalHeader).to.have.attribute('active');
|
||||
expect(showEventFired).to.be.false;
|
||||
expect(hideEventFired).to.be.false;
|
||||
return expectOnlyOneTabPanelToBeActive(tabGroup, 'general-tab-content');
|
||||
};
|
||||
|
||||
it('selects a tab by clicking on it', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
|
||||
return expectCustomTabToBeActiveAfter(tabGroup, () => clickOnElement(customHeader!));
|
||||
});
|
||||
|
||||
it('does not change if the active tab is reselected', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="custom">Custom</sl-tab>
|
||||
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
const generalHeader = queryByTestId(tabGroup, 'general-header');
|
||||
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => clickOnElement(generalHeader!));
|
||||
});
|
||||
|
||||
it('does not change if a disabled tab is clicked', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="disabled" data-testid="disabled-header" disabled>disabled</sl-tab>
|
||||
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="disabled">This is the disabled tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
const disabledHeader = queryByTestId(tabGroup, 'disabled-header');
|
||||
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => clickOnElement(disabledHeader!));
|
||||
});
|
||||
|
||||
it('selects a tab by using the arrow keys', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
return expectCustomTabToBeActiveAfter(tabGroup, () => sendKeys({ press: 'ArrowRight' }));
|
||||
});
|
||||
|
||||
it('selects a tab by using the arrow keys and enter if activation is set to manual', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
tabGroup.activation = 'manual';
|
||||
|
||||
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
|
||||
generalHeader.focus();
|
||||
|
||||
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
|
||||
expect(customHeader).not.to.have.attribute('active');
|
||||
|
||||
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<CustomEvent>;
|
||||
await sendKeys({ press: 'ArrowRight' });
|
||||
await aTimeout(0);
|
||||
expect(generalHeader).to.have.attribute('active');
|
||||
|
||||
await sendKeys({ press: 'Enter' });
|
||||
|
||||
expect(customHeader).to.have.attribute('active');
|
||||
await expectPromiseToHaveName(showEventPromise, 'custom');
|
||||
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
|
||||
});
|
||||
|
||||
it('does not allow selection of disabled tabs with arrow keys', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="disabled" disabled>Disabled</sl-tab>
|
||||
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="disabled">This is the custom tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => sendKeys({ press: 'ArrowRight' }));
|
||||
});
|
||||
|
||||
it('selects a tab by using the show function', async () => {
|
||||
const tabGroup = await fixture<SlTabGroup>(html`
|
||||
<sl-tab-group>
|
||||
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
|
||||
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
|
||||
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
|
||||
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
`);
|
||||
|
||||
return expectCustomTabToBeActiveAfter(tabGroup, () => {
|
||||
tabGroup.show('custom');
|
||||
return aTimeout(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,14 +35,6 @@ describe('<sl-tab-panel>', () => {
|
||||
expect(el.getAttribute('aria-hidden')).to.equal('false');
|
||||
});
|
||||
|
||||
it('changing active should always update aria-hidden role', async () => {
|
||||
const el = await fixture<SlTabPanel>(html` <sl-tab-panel>Test</sl-tab-panel> `);
|
||||
|
||||
el.active = true;
|
||||
await aTimeout(100);
|
||||
expect(el.getAttribute('aria-hidden')).to.equal('false');
|
||||
});
|
||||
|
||||
it('passed id should be used', async () => {
|
||||
const el = await fixture<SlTabPanel>(html` <sl-tab-panel id="test-id">Test</sl-tab-panel> `);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
|
||||
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests';
|
||||
import { sendKeys } from '@web/test-runner-commands';
|
||||
import { serialize } from '../../utilities/form';
|
||||
import sinon from 'sinon';
|
||||
@@ -147,6 +148,8 @@ describe('<sl-textarea>', () => {
|
||||
el.focus();
|
||||
await sendKeys({ press: 'b' });
|
||||
await el.updateComplete;
|
||||
el.blur();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.checkValidity()).to.be.true;
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.false;
|
||||
@@ -167,10 +170,24 @@ describe('<sl-textarea>', () => {
|
||||
await sendKeys({ press: 'a' });
|
||||
await sendKeys({ press: 'Backspace' });
|
||||
await el.updateComplete;
|
||||
el.blur();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(el.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
|
||||
it('should receive validation attributes ("states") even when novalidate is used on the parent form', async () => {
|
||||
const el = await fixture<HTMLFormElement>(html` <form novalidate><sl-textarea required></sl-textarea></form> `);
|
||||
const textarea = el.querySelector<SlTextarea>('sl-textarea')!;
|
||||
|
||||
expect(textarea.hasAttribute('data-required')).to.be.true;
|
||||
expect(textarea.hasAttribute('data-optional')).to.be.false;
|
||||
expect(textarea.hasAttribute('data-invalid')).to.be.true;
|
||||
expect(textarea.hasAttribute('data-valid')).to.be.false;
|
||||
expect(textarea.hasAttribute('data-user-invalid')).to.be.false;
|
||||
expect(textarea.hasAttribute('data-user-valid')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting a form', () => {
|
||||
@@ -201,6 +218,8 @@ describe('<sl-textarea>', () => {
|
||||
textarea.focus();
|
||||
await sendKeys({ type: 'test' });
|
||||
await textarea.updateComplete;
|
||||
textarea.blur();
|
||||
await textarea.updateComplete;
|
||||
|
||||
expect(textarea.hasAttribute('data-user-invalid')).to.be.true;
|
||||
expect(textarea.hasAttribute('data-user-valid')).to.be.false;
|
||||
@@ -274,4 +293,6 @@ describe('<sl-textarea>', () => {
|
||||
expect(textarea.spellcheck).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
runFormControlBaseTests('sl-textarea');
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
|
||||
* @event sl-change - Emitted when an alteration to the control's value is committed by the user.
|
||||
* @event sl-focus - Emitted when the control gains focus.
|
||||
* @event sl-input - Emitted when the control receives input.
|
||||
* @event sl-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and help text.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
@@ -37,7 +38,9 @@ import type { ShoelaceFormControl } from '../../internal/shoelace-element';
|
||||
export default class SlTextarea extends ShoelaceElement implements ShoelaceFormControl {
|
||||
static styles: CSSResultGroup = styles;
|
||||
|
||||
private readonly formControlController = new FormControlController(this);
|
||||
private readonly formControlController = new FormControlController(this, {
|
||||
assumeInteractionOn: ['sl-blur', 'sl-input']
|
||||
});
|
||||
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
|
||||
private resizeObserver: ResizeObserver;
|
||||
|
||||
@@ -133,6 +136,16 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
|
||||
/** The default value of the form control. Primarily used for resetting the form control. */
|
||||
@defaultValue() defaultValue = '';
|
||||
|
||||
/** Gets the validity state object */
|
||||
get validity() {
|
||||
return this.input.validity;
|
||||
}
|
||||
|
||||
/** Gets the validation message */
|
||||
get validationMessage() {
|
||||
return this.input.validationMessage;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight());
|
||||
@@ -173,6 +186,11 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
|
||||
this.emit('sl-input');
|
||||
}
|
||||
|
||||
private handleInvalid(event: Event) {
|
||||
this.formControlController.setValidity(false);
|
||||
this.formControlController.emitInvalidEvent(event);
|
||||
}
|
||||
|
||||
private setTextareaHeight() {
|
||||
if (this.resize === 'auto') {
|
||||
this.input.style.height = 'auto';
|
||||
@@ -258,7 +276,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks for validity but does not show the browser's validation message. */
|
||||
/** Checks for validity but does not show a validation message. Returns `true` when valid and `false` when invalid. */
|
||||
checkValidity() {
|
||||
return this.input.checkValidity();
|
||||
}
|
||||
@@ -342,6 +360,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
|
||||
aria-describedby="help-text"
|
||||
@change=${this.handleChange}
|
||||
@input=${this.handleInput}
|
||||
@invalid=${this.handleInvalid}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
></textarea>
|
||||
|
||||
@@ -9,18 +9,23 @@ import type SlButton from '../components/button/button';
|
||||
//
|
||||
export const formCollections: WeakMap<HTMLFormElement, Set<ShoelaceFormControl>> = new WeakMap();
|
||||
|
||||
//
|
||||
// We store a WeakMap of controls that users have interacted with. This allows us to determine the interaction state
|
||||
// without littering the DOM with additional data attributes.
|
||||
//
|
||||
const userInteractedControls: WeakMap<ShoelaceFormControl, boolean> = new WeakMap();
|
||||
|
||||
//
|
||||
// We store a WeakMap of reportValidity() overloads so we can override it when form controls connect to the DOM and
|
||||
// restore the original behavior when they disconnect.
|
||||
//
|
||||
const reportValidityOverloads: WeakMap<HTMLFormElement, () => boolean> = new WeakMap();
|
||||
|
||||
//
|
||||
// We store a Set of controls that users have interacted with. This allows us to determine the interaction state
|
||||
// without littering the DOM with additional data attributes.
|
||||
//
|
||||
const userInteractedControls: Set<ShoelaceFormControl> = new Set();
|
||||
|
||||
//
|
||||
// We store a WeakMap of interactions for each form control so we can track when all conditions are met for validation.
|
||||
//
|
||||
const interactions = new WeakMap<ShoelaceFormControl, string[]>();
|
||||
|
||||
export interface FormControlControllerOptions {
|
||||
/** A function that returns the form containing the form control. */
|
||||
form: (input: ShoelaceFormControl) => HTMLFormElement | null;
|
||||
@@ -39,8 +44,13 @@ export interface FormControlControllerOptions {
|
||||
reportValidity: (input: ShoelaceFormControl) => boolean;
|
||||
/** A function that sets the form control's value */
|
||||
setValue: (input: ShoelaceFormControl, value: unknown) => void;
|
||||
/**
|
||||
* An array of event names to listen to. When all events in the list are emitted, the control will receive validity
|
||||
* states such as user-valid and user-invalid.user interacted validity states. */
|
||||
assumeInteractionOn: string[];
|
||||
}
|
||||
|
||||
/** A reactive controller to allow form controls to participate in form submission, validation, etc. */
|
||||
export class FormControlController implements ReactiveController {
|
||||
host: ShoelaceFormControl & ReactiveControllerHost;
|
||||
form?: HTMLFormElement | null;
|
||||
@@ -68,13 +78,14 @@ export class FormControlController implements ReactiveController {
|
||||
disabled: input => input.disabled ?? false,
|
||||
reportValidity: input => (typeof input.reportValidity === 'function' ? input.reportValidity() : true),
|
||||
setValue: (input, value: string) => (input.value = value),
|
||||
assumeInteractionOn: ['sl-input'],
|
||||
...options
|
||||
};
|
||||
this.handleFormData = this.handleFormData.bind(this);
|
||||
this.handleFormSubmit = this.handleFormSubmit.bind(this);
|
||||
this.handleFormReset = this.handleFormReset.bind(this);
|
||||
this.reportFormValidity = this.reportFormValidity.bind(this);
|
||||
this.handleUserInput = this.handleUserInput.bind(this);
|
||||
this.handleInteraction = this.handleInteraction.bind(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
@@ -84,12 +95,21 @@ export class FormControlController implements ReactiveController {
|
||||
this.attachForm(form);
|
||||
}
|
||||
|
||||
this.host.addEventListener('sl-input', this.handleUserInput);
|
||||
// Listen for interactions
|
||||
interactions.set(this.host, []);
|
||||
this.options.assumeInteractionOn.forEach(event => {
|
||||
this.host.addEventListener(event, this.handleInteraction);
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this.detachForm();
|
||||
this.host.removeEventListener('sl-input', this.handleUserInput);
|
||||
|
||||
// Clean up interactions
|
||||
interactions.delete(this.host);
|
||||
this.options.assumeInteractionOn.forEach(event => {
|
||||
this.host.removeEventListener(event, this.handleInteraction);
|
||||
});
|
||||
}
|
||||
|
||||
hostUpdated() {
|
||||
@@ -107,7 +127,7 @@ export class FormControlController implements ReactiveController {
|
||||
}
|
||||
|
||||
if (this.host.hasUpdated) {
|
||||
this.setValidity(this.host.checkValidity());
|
||||
this.setValidity(this.host.validity.valid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,11 +215,20 @@ export class FormControlController implements ReactiveController {
|
||||
private handleFormReset() {
|
||||
this.options.setValue(this.host, this.options.defaultValue(this.host));
|
||||
this.setUserInteracted(this.host, false);
|
||||
interactions.set(this.host, []);
|
||||
}
|
||||
|
||||
private async handleUserInput() {
|
||||
await this.host.updateComplete;
|
||||
this.setUserInteracted(this.host, true);
|
||||
private handleInteraction(event: Event) {
|
||||
const emittedEvents = interactions.get(this.host)!;
|
||||
|
||||
if (!emittedEvents.includes(event.type)) {
|
||||
emittedEvents.push(event.type);
|
||||
}
|
||||
|
||||
// Mark it as user-interacted as soon as all associated events have been emitted
|
||||
if (emittedEvents.length === this.options.assumeInteractionOn.length) {
|
||||
this.setUserInteracted(this.host, true);
|
||||
}
|
||||
}
|
||||
|
||||
private reportFormValidity() {
|
||||
@@ -233,7 +262,12 @@ export class FormControlController implements ReactiveController {
|
||||
}
|
||||
|
||||
private setUserInteracted(el: ShoelaceFormControl, hasInteracted: boolean) {
|
||||
userInteractedControls.set(el, hasInteracted);
|
||||
if (hasInteracted) {
|
||||
userInteractedControls.add(el);
|
||||
} else {
|
||||
userInteractedControls.delete(el);
|
||||
}
|
||||
|
||||
el.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -266,6 +300,11 @@ export class FormControlController implements ReactiveController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the associated `<form>` element, if one exists. */
|
||||
getForm() {
|
||||
return this.form ?? null;
|
||||
}
|
||||
|
||||
/** Resets the form, restoring all the control to their default value */
|
||||
reset(invoker?: HTMLInputElement | SlButton) {
|
||||
this.doAction('reset', invoker);
|
||||
@@ -284,7 +323,7 @@ export class FormControlController implements ReactiveController {
|
||||
*/
|
||||
setValidity(isValid: boolean) {
|
||||
const host = this.host;
|
||||
const hasInteracted = Boolean(userInteractedControls.get(host));
|
||||
const hasInteracted = Boolean(userInteractedControls.has(host));
|
||||
const required = Boolean(host.required);
|
||||
|
||||
//
|
||||
@@ -293,31 +332,77 @@ export class FormControlController implements ReactiveController {
|
||||
//
|
||||
// See this RFC for more details: https://github.com/shoelace-style/shoelace/issues/1011
|
||||
//
|
||||
if (this.form?.noValidate) {
|
||||
// Form validation is disabled, remove the attributes
|
||||
host.removeAttribute('data-required');
|
||||
host.removeAttribute('data-optional');
|
||||
host.removeAttribute('data-invalid');
|
||||
host.removeAttribute('data-valid');
|
||||
host.removeAttribute('data-user-invalid');
|
||||
host.removeAttribute('data-user-valid');
|
||||
} else {
|
||||
// Form validation is enabled, set the attributes
|
||||
host.toggleAttribute('data-required', required);
|
||||
host.toggleAttribute('data-optional', !required);
|
||||
host.toggleAttribute('data-invalid', !isValid);
|
||||
host.toggleAttribute('data-valid', isValid);
|
||||
host.toggleAttribute('data-user-invalid', !isValid && hasInteracted);
|
||||
host.toggleAttribute('data-user-valid', isValid && hasInteracted);
|
||||
}
|
||||
host.toggleAttribute('data-required', required);
|
||||
host.toggleAttribute('data-optional', !required);
|
||||
host.toggleAttribute('data-invalid', !isValid);
|
||||
host.toggleAttribute('data-valid', isValid);
|
||||
host.toggleAttribute('data-user-invalid', !isValid && hasInteracted);
|
||||
host.toggleAttribute('data-user-valid', isValid && hasInteracted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the form control's validity based on the current value of `host.checkValidity()`. Call this when anything
|
||||
* Updates the form control's validity based on the current value of `host.validity.valid`. Call this when anything
|
||||
* that affects constraint validation changes so the component receives the correct validity states.
|
||||
*/
|
||||
updateValidity() {
|
||||
const host = this.host;
|
||||
this.setValidity(host.checkValidity());
|
||||
this.setValidity(host.validity.valid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a non-bubbling, cancelable custom event of type `sl-invalid`.
|
||||
* If the `sl-invalid` event will be cancelled then the original `invalid`
|
||||
* event (which may have been passed as argument) will also be cancelled.
|
||||
* If no original `invalid` event has been passed then the `sl-invalid`
|
||||
* event will be cancelled before being dispatched.
|
||||
*/
|
||||
emitInvalidEvent(originalInvalidEvent?: Event) {
|
||||
const slInvalidEvent = new CustomEvent<void>('sl-invalid', {
|
||||
bubbles: false,
|
||||
composed: false,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
if (!originalInvalidEvent) {
|
||||
slInvalidEvent.preventDefault();
|
||||
}
|
||||
|
||||
if (!this.host.dispatchEvent(slInvalidEvent)) {
|
||||
originalInvalidEvent?.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Predefined common validity states.
|
||||
* All of them are read-only.
|
||||
*/
|
||||
|
||||
// A validity state object that represents `valid`
|
||||
export const validValidityState: ValidityState = Object.freeze({
|
||||
badInput: false,
|
||||
customError: false,
|
||||
patternMismatch: false,
|
||||
rangeOverflow: false,
|
||||
rangeUnderflow: false,
|
||||
stepMismatch: false,
|
||||
tooLong: false,
|
||||
tooShort: false,
|
||||
typeMismatch: false,
|
||||
valid: true,
|
||||
valueMissing: false
|
||||
});
|
||||
|
||||
// A validity state object that represents `value missing`
|
||||
export const valueMissingValidityState: ValidityState = Object.freeze({
|
||||
...validValidityState,
|
||||
valid: false,
|
||||
valueMissing: true
|
||||
});
|
||||
|
||||
// A validity state object that represents a custom error
|
||||
export const customErrorValidityState: ValidityState = Object.freeze({
|
||||
...validValidityState,
|
||||
valid: false,
|
||||
customError: true
|
||||
});
|
||||
|
||||
@@ -40,8 +40,13 @@ export interface ShoelaceFormControl extends ShoelaceElement {
|
||||
minlength?: number;
|
||||
maxlength?: number;
|
||||
|
||||
// Validation properties
|
||||
readonly validity: ValidityState;
|
||||
readonly validationMessage: string;
|
||||
|
||||
// Validation methods
|
||||
checkValidity: () => boolean;
|
||||
/** Checks for validity and shows the browser's validation message if the control is invalid. */
|
||||
reportValidity: () => boolean;
|
||||
setCustomValidity: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
|
||||
/** A reactive controller that determines when slots exist. */
|
||||
export class HasSlotController implements ReactiveController {
|
||||
host: ReactiveControllerHost & Element;
|
||||
slotNames: string[] = [];
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { sendMouse } from '@web/test-runner-commands';
|
||||
|
||||
/** A testing utility that measures an element's position and clicks on it. */
|
||||
export async function clickOnElement(
|
||||
/** The element to click */
|
||||
el: Element,
|
||||
/** The location of the element to click */
|
||||
position: 'top' | 'right' | 'bottom' | 'left' | 'center' = 'center',
|
||||
/** The horizontal offset to apply to the position when clicking */
|
||||
offsetX = 0,
|
||||
/** The vertical offset to apply to the position when clicking */
|
||||
offsetY = 0
|
||||
) {
|
||||
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);
|
||||
@@ -41,6 +31,36 @@ export async function clickOnElement(
|
||||
|
||||
clickX += offsetX;
|
||||
clickY += offsetY;
|
||||
return { clickX, clickY };
|
||||
}
|
||||
|
||||
/** A testing utility that measures an element's position and clicks on it. */
|
||||
export async function clickOnElement(
|
||||
/** The element to click */
|
||||
el: Element,
|
||||
/** The location of the element to click */
|
||||
position: 'top' | 'right' | 'bottom' | 'left' | 'center' = 'center',
|
||||
/** The horizontal offset to apply to the position when clicking */
|
||||
offsetX = 0,
|
||||
/** The vertical offset to apply to the position when clicking */
|
||||
offsetY = 0
|
||||
) {
|
||||
const { clickX, clickY } = determineMousePosition(el, position, offsetX, offsetY);
|
||||
|
||||
await sendMouse({ type: 'click', position: [clickX, clickY] });
|
||||
}
|
||||
|
||||
export async function moveMouseOnElement(
|
||||
/** The element to click */
|
||||
el: Element,
|
||||
/** The location of the element to click */
|
||||
position: 'top' | 'right' | 'bottom' | 'left' | 'center' = 'center',
|
||||
/** The horizontal offset to apply to the position when clicking */
|
||||
offsetX = 0,
|
||||
/** The vertical offset to apply to the position when clicking */
|
||||
offsetY = 0
|
||||
) {
|
||||
const { clickX, clickY } = determineMousePosition(el, position, offsetX, offsetY);
|
||||
|
||||
await sendMouse({ type: 'move', position: [clickX, clickY] });
|
||||
}
|
||||
|
||||
14
src/internal/test/data-testid-helpers.ts
Normal file
14
src/internal/test/data-testid-helpers.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Allows you to find a DOM element based on the value of its `data-testid` attribute.
|
||||
* This attribute can be used to decouple identifying dom elements for testing from
|
||||
* styling (which is typically done via class selectors) or other ids which serve
|
||||
* different purposes.
|
||||
* See also https://kentcdodds.com/blog/making-your-ui-tests-resilient-to-change
|
||||
* Inspired by https://testing-library.com/docs/queries/bytestid/
|
||||
* @param {HTMLElement} container - A parent element of the DOM element to find
|
||||
* @param {string} testId - The value of the `data-testid` attribute of the component to find.
|
||||
* @returns The found element or null if there was no such element
|
||||
*/
|
||||
export const queryByTestId = <T extends Element>(container: HTMLElement, testId: string): T | null => {
|
||||
return container.querySelector<T>(`[data-testid="${testId}"]`);
|
||||
};
|
||||
20
src/internal/test/element-visible-overflow.ts
Normal file
20
src/internal/test/element-visible-overflow.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Given a parent element featuring `overflow: hidden` and a child element inside the parent, this
|
||||
* function determines whether the child will be visible taking only the overflow of the parent into account
|
||||
* Id does NOT check whether it is hidden or overlapped by another element
|
||||
* It basically checks whether the bounding rects of the parent and the child overlap
|
||||
*
|
||||
* @param {HTMLElement} outerElement - The parent element
|
||||
* @param {HTMLElement} innerElement - the child element
|
||||
* @returns {Boolean} whether the two elements overlap
|
||||
*/
|
||||
export const isElementVisibleFromOverflow = (outerElement: Element, innerElement: Element): boolean => {
|
||||
const outerRect = outerElement.getBoundingClientRect();
|
||||
const innerRect = innerElement.getBoundingClientRect();
|
||||
return (
|
||||
outerRect.top <= innerRect.bottom &&
|
||||
innerRect.top <= outerRect.bottom &&
|
||||
outerRect.left <= innerRect.right &&
|
||||
innerRect.left <= outerRect.right
|
||||
);
|
||||
};
|
||||
307
src/internal/test/form-control-base-tests.ts
Normal file
307
src/internal/test/form-control-base-tests.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { expect, fixture } from '@open-wc/testing';
|
||||
import type { ShoelaceFormControl } from '../shoelace-element';
|
||||
|
||||
type CreateControlFn = () => Promise<ShoelaceFormControl>;
|
||||
|
||||
/** Runs a set of generic tests for Shoelace form controls */
|
||||
export function runFormControlBaseTests<T extends ShoelaceFormControl = ShoelaceFormControl>(
|
||||
tagNameOrConfig:
|
||||
| string
|
||||
| {
|
||||
tagName: string;
|
||||
init?: (control: T) => void;
|
||||
variantName: string;
|
||||
}
|
||||
) {
|
||||
const isStringArg = typeof tagNameOrConfig === 'string';
|
||||
const tagName = isStringArg ? tagNameOrConfig : tagNameOrConfig.tagName;
|
||||
|
||||
// component initialization function or null
|
||||
const init =
|
||||
isStringArg || !tagNameOrConfig.init //
|
||||
? null
|
||||
: tagNameOrConfig.init || null;
|
||||
|
||||
// either `<tagName>` or `<tagName> (<variantName>)
|
||||
const displayName = isStringArg //
|
||||
? tagName
|
||||
: `${tagName} (${tagNameOrConfig.variantName})`;
|
||||
|
||||
// creates a testable form control instance
|
||||
const createControl = async () => {
|
||||
const control = await createFormControl<T>(tagName);
|
||||
init?.(control);
|
||||
return control;
|
||||
};
|
||||
|
||||
runAllValidityTests(tagName, displayName, createControl);
|
||||
}
|
||||
|
||||
//
|
||||
// Applicable for all Shoelace form controls. This function checks the behavior of:
|
||||
// - `.validity`
|
||||
// - `.validationMessage`,
|
||||
// - `.checkValidity()`
|
||||
// - `.reportValidity()`
|
||||
// - `.setCustomValidity(msg)`
|
||||
//
|
||||
function runAllValidityTests(
|
||||
tagName: string, //
|
||||
displayName: string,
|
||||
createControl: () => Promise<ShoelaceFormControl>
|
||||
) {
|
||||
// will be used later to retrieve meta information about the control
|
||||
describe(`Form validity base test for ${displayName}`, async () => {
|
||||
it('should have a property `validity` of type `object`', async () => {
|
||||
const control = await createControl();
|
||||
expect(control).satisfy(() => control.validity !== null && typeof control.validity === 'object');
|
||||
});
|
||||
|
||||
it('should have a property `validationMessage` of type `string`', async () => {
|
||||
const control = await createControl();
|
||||
expect(control).satisfy(() => typeof control.validationMessage === 'string');
|
||||
});
|
||||
|
||||
it('should implement method `checkValidity`', async () => {
|
||||
const control = await createControl();
|
||||
expect(control).satisfies(() => typeof control.checkValidity === 'function');
|
||||
});
|
||||
|
||||
it('should implement method `setCustomValidity`', async () => {
|
||||
const control = await createControl();
|
||||
expect(control).satisfies(() => typeof control.setCustomValidity === 'function');
|
||||
});
|
||||
|
||||
it('should implement method `reportValidity`', async () => {
|
||||
const control = await createControl();
|
||||
expect(control).satisfies(() => typeof control.reportValidity === 'function');
|
||||
});
|
||||
|
||||
it('should be valid initially', async () => {
|
||||
const control = await createControl();
|
||||
expect(control.validity.valid).to.equal(true);
|
||||
});
|
||||
|
||||
it('should make sure that calling `.checkValidity()` will return `true` when valid', async () => {
|
||||
const control = await createControl();
|
||||
expect(control.checkValidity()).to.equal(true);
|
||||
});
|
||||
|
||||
it('should make sure that calling `.reportValidity()` will return `true` when valid', async () => {
|
||||
const control = await createControl();
|
||||
expect(control.reportValidity()).to.equal(true);
|
||||
});
|
||||
|
||||
it('should not emit an `sl-invalid` event when `.checkValidity()` is called while valid', async () => {
|
||||
const control = await createControl();
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
|
||||
it('should not emit an `sl-invalid` event when `.reportValidity()` is called while valid', async () => {
|
||||
const control = await createControl();
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
|
||||
// TODO: As soon as `SlRadioGroup` has a property `disabled` this
|
||||
// condition can be removed
|
||||
if (tagName !== 'sl-radio-group') {
|
||||
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case while disabled', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
control.disabled = true;
|
||||
await control.updateComplete;
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
|
||||
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case while disabled', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
control.disabled = true;
|
||||
await control.updateComplete;
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Run special tests depending on component type
|
||||
|
||||
const mode = getMode(await createControl());
|
||||
|
||||
if (mode === 'slButtonOfTypeButton') {
|
||||
runSpecialTests_slButtonOfTypeButton(createControl);
|
||||
} else if (mode === 'slButtonWithHRef') {
|
||||
runSpecialTests_slButtonWithHref(createControl);
|
||||
} else {
|
||||
runSpecialTests_standard(createControl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Special tests for <sl-button type="button">
|
||||
//
|
||||
function runSpecialTests_slButtonOfTypeButton(createControl: CreateControlFn) {
|
||||
it('should make sure that `.validity.valid` is `false` in custom error case', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
expect(control.validity.valid).to.equal(false);
|
||||
});
|
||||
|
||||
it('should make sure that calling `.checkValidity()` will still return `true` when custom error has been set', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
expect(control.checkValidity()).to.equal(true);
|
||||
});
|
||||
|
||||
it('should make sure that calling `.reportValidity()` will still return `true` when custom error has been set', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
expect(control.reportValidity()).to.equal(true);
|
||||
});
|
||||
|
||||
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case, and not disabled', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
control.disabled = false;
|
||||
await control.updateComplete;
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
|
||||
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case, and not disabled', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
control.disabled = false;
|
||||
await control.updateComplete;
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
|
||||
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Special tests for <sl-button href="...">
|
||||
//
|
||||
function runSpecialTests_slButtonWithHref(createControl: CreateControlFn) {
|
||||
it('should make sure that calling `.checkValidity()` will return `true` in custom error case', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
expect(control.checkValidity()).to.equal(true);
|
||||
});
|
||||
|
||||
it('should make sure that calling `.reportValidity()` will return `true` in custom error case', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
expect(control.reportValidity()).to.equal(true);
|
||||
});
|
||||
|
||||
it('should not emit an `sl-invalid` event when `.checkValidity()` is called in custom error case', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
await control.updateComplete;
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
|
||||
it('should not emit an `sl-invalid` event when `.reportValidity()` is called in custom error case', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
await control.updateComplete;
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
|
||||
expect(emittedEvents.length).to.equal(0);
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Special tests for all components with standard behavior
|
||||
//
|
||||
function runSpecialTests_standard(createControl: CreateControlFn) {
|
||||
it('should make sure that `.validity.valid` is `false` in custom error case', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
expect(control.validity.valid).to.equal(false);
|
||||
});
|
||||
|
||||
it('should make sure that calling `.checkValidity()` will return `false` in custom error case', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
expect(control.checkValidity()).to.equal(false);
|
||||
});
|
||||
|
||||
it('should make sure that calling `.reportValidity()` will return `false` in custom error case', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
expect(control.reportValidity()).to.equal(false);
|
||||
});
|
||||
|
||||
it('should emit an `sl-invalid` event when `.checkValidity()` is called in custom error case and not disabled', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
control.disabled = false;
|
||||
await control.updateComplete;
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.checkValidity());
|
||||
expect(emittedEvents.length).to.equal(1);
|
||||
});
|
||||
|
||||
it('should emit an `sl-invalid` event when `.reportValidity()` is called in custom error case and not disabled', async () => {
|
||||
const control = await createControl();
|
||||
control.setCustomValidity('error');
|
||||
control.disabled = false;
|
||||
await control.updateComplete;
|
||||
const emittedEvents = checkEventEmissions(control, 'sl-invalid', () => control.reportValidity());
|
||||
expect(emittedEvents.length).to.equal(1);
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Local helper functions
|
||||
//
|
||||
|
||||
// Creates a testable Shoelace form control instance
|
||||
async function createFormControl<T extends ShoelaceFormControl = ShoelaceFormControl>(tagName: string): Promise<T> {
|
||||
return await fixture<T>(`<${tagName}></${tagName}>`);
|
||||
}
|
||||
|
||||
// Runs an action while listening for emitted events of a given type. Returns an array of all events of the given type
|
||||
// that have been been emitted while the action was running.
|
||||
function checkEventEmissions(control: ShoelaceFormControl, eventType: string, action: () => void): Event[] {
|
||||
const emittedEvents: Event[] = [];
|
||||
|
||||
const eventHandler = (event: Event) => {
|
||||
emittedEvents.push(event);
|
||||
};
|
||||
|
||||
try {
|
||||
control.addEventListener(eventType, eventHandler);
|
||||
action();
|
||||
} finally {
|
||||
control.removeEventListener(eventType, eventHandler);
|
||||
}
|
||||
|
||||
return emittedEvents;
|
||||
}
|
||||
|
||||
// Component `sl-button` behaves quite different to the other components. To keep things simple we use simple conditions
|
||||
// here. `sl-button` might stay the only component in Shoelace core behaves that way, so we just hard code it here.
|
||||
function getMode(control: ShoelaceFormControl) {
|
||||
if (
|
||||
control.localName === 'sl-button' && //
|
||||
'href' in control &&
|
||||
'type' in control &&
|
||||
control.type === 'button' &&
|
||||
!control.href
|
||||
) {
|
||||
return 'slButtonOfTypeButton';
|
||||
}
|
||||
|
||||
// <sl-button href="...">
|
||||
if (control.localName === 'sl-button' && 'href' in control && !!control.href) {
|
||||
return 'slButtonWithHRef';
|
||||
}
|
||||
|
||||
// all other components
|
||||
return 'standard';
|
||||
}
|
||||
36
src/internal/test/wait-for-scrolling.ts
Normal file
36
src/internal/test/wait-for-scrolling.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Wait until an element has stopped scrolling
|
||||
* This considers the element to have stopped scrolling, as soon as it did not change its
|
||||
* scroll position for 20 successive animation frames
|
||||
* @param {HTMLElement} element - The element which is scrolled
|
||||
* @param {numeric} timeoutInMs - A timeout in ms. If the timeout has elapsed, the promise rejects
|
||||
* @returns A promise which resolves after the scrolling has stopped
|
||||
*/
|
||||
export const waitForScrollingToEnd = (element: Element, timeoutInMs = 500): Promise<void> => {
|
||||
let lastLeft = element.scrollLeft;
|
||||
let lastTop = element.scrollTop;
|
||||
let framesWithoutChange = 0;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
reject(new Error('Waiting for scroll end timed out'));
|
||||
}, timeoutInMs);
|
||||
|
||||
function checkScrollingChanged() {
|
||||
if (element.scrollLeft !== lastLeft || element.scrollTop !== lastTop) {
|
||||
framesWithoutChange = 0;
|
||||
lastLeft = window.scrollX;
|
||||
lastTop = window.scrollY;
|
||||
} else {
|
||||
framesWithoutChange++;
|
||||
if (framesWithoutChange >= 20) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(checkScrollingChanged);
|
||||
}
|
||||
|
||||
checkScrollingChanged();
|
||||
});
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export default css`
|
||||
font-size: var(--sl-input-label-font-size-medium);
|
||||
}
|
||||
|
||||
.form-control--has-label.form-control--large .form-control_label {
|
||||
.form-control--has-label.form-control--large .form-control__label {
|
||||
font-size: var(--sl-input-label-font-size-large);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user