Initial SSR implementation (#157)

* continued ssr work

* continued ssr work

* prettier

* all components now rendering

* everything finally works

* fix type issues

* working on breadcrumb

* working on breadcrumb

* radio group

* convert all tests to ssr

* prettier

* test suite finally passing

* add layout stuff

* add changelog

* fix TS issue

* fix tests

* fixing deploy stuff

* get QR code displaying

* fix tests

* fix tests

* prettier

* condense hydration stuff

* prettier

* comment out range test

* fixing issues

* use base fixtures

* fixing examples

* dont vendor

* fix import of hydration support

* adding notes

* add notesg

* add ssr loader

* fix build

* prettier

* add notes

* add notes

* prettier

* fixing bundled stuff

* remove cdn

* remove cdn

* prettier

* fiixng tests

* prettier

* split jobs??

* prettier

* fix build stuff

* add reset mouse and await aTimeout

* prettier

* fix improper tests

* prettier

* bail on first

* fix linting

* only test form with client

* redundancy on ssr-loader??

* maybe this will work

* prettier

* try callout now

* fix form.test.ts

* fix form.test.ts

* prettier

* fix forms

* fix forms

* try again

* prettier

* add some awaits

* prettier

* comment out broken SSR tests

* prettier

* comment out broken SSR tests

* prettier

* dont skip in CI

* upgrade playwright to beta

* prettier

* try some trickery

* try some trickery

* await updateComplete

* try to fix form.test.ts

* import hydrateable elements 1 time

* prettier

* fix input defaultValue issues

* fix form controls to behave like their native counterpartS

* add changelog entry

* prettier

* fix unexpected behavior with range / button
This commit is contained in:
Konnor Rogers
2024-09-11 10:25:42 -04:00
committed by GitHub
parent cd9fa25a2e
commit 14914abf65
129 changed files with 12195 additions and 10038 deletions

View File

@@ -1,5 +1,36 @@
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
# # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
# name: Node.js CI
# on:
# push:
# branches: [next]
# pull_request:
# branches: [next]
# jobs:
# build:
# runs-on: ubuntu-latest
# strategy:
# matrix:
# node-version: [20.x]
# # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
# steps:
# - uses: actions/checkout@v4
# - name: Use Node.js ${{ matrix.node-version }}
# uses: actions/setup-node@v4
# with:
# node-version: ${{ matrix.node-version }}
# cache: 'npm'
# - run: npm ci
# - run: npx playwright install-deps
# - run: npm run verify
# # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
@@ -10,21 +41,61 @@ on:
branches: [next]
jobs:
build:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
node-version: [20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npx playwright install-deps
- run: npm ci
- run: npm run verify
- run: npm run prettier && npm run lint
test_client:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npx playwright uninstall --all && npx playwright install --force chromium firefox webkit --with-deps
- run: npm run build
# --bail to fail on first failing test.
- run: CSR_ONLY="true" npm run test -- --bail
test_ssr:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npx playwright uninstall --all && npx playwright install --force chromium firefox webkit --with-deps
- run: npm run build
- run: SSR_ONLY="true" npm run test -- --bail

5
.gitignore vendored
View File

@@ -3,9 +3,10 @@ _site
.DS_Store
package.json
package-lock.json
dist
dist/
dist-cdn/
docs/public/pagefind
node_modules
src/react
cdn
.astro
cdn/

View File

@@ -7,7 +7,7 @@ import fs from 'fs';
const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const { name, description, version, author, homepage, license } = packageData;
const outdir = 'dist';
const outdir = 'dist-cdn';
function replace(string, terms) {
terms.forEach(({ from, to }) => {
@@ -162,7 +162,7 @@ export default {
}),
customElementJetBrainsPlugin({
outdir: './dist',
outdir: './dist-cdn',
excludeCss: true,
packageJson: false,
referencesTemplate: (_, tag) => {

View File

@@ -11,6 +11,8 @@ import { searchPlugin } from './_utils/search.js';
import { readFile } from 'fs/promises';
import { outlinePlugin } from './_utils/outline.js';
import { getComponents } from './_utils/manifest.js';
import litPlugin from '@lit-labs/eleventy-plugin-lit';
import process from 'process';
const packageData = JSON.parse(await readFile('./package.json', 'utf-8'));
@@ -106,6 +108,26 @@ export default function (eleventyConfig) {
])
);
const omittedModules = [];
// problematic components:
// animation (breaks on navigation + ssr with Turbo)
// mutation-observer (why SSR this?)
// resize-observer (why SSR this?)
// tooltip (why SSR this?)
const componentModules = getComponents()
// .filter(component => !omittedModules.includes(component.tagName.split(/wa-/)[1]))
.map(component => {
const name = component.tagName.split(/wa-/)[1];
return `./dist/components/${name}/${name}.js`;
});
eleventyConfig.addPlugin(litPlugin, {
mode: 'worker',
componentModules
});
// Build the search index
eleventyConfig.addPlugin(
searchPlugin({

View File

@@ -13,6 +13,9 @@
<link rel="apple-touch-icon" href="/assets/images/app-icon.png">
{# Scripts #}
{# Hydration stuff #}
<script src="/assets/scripts/hydration-errors.js"></script>
<link rel="stylesheet" href="/assets/styles/hydration-errors.css">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/+esm"></script>
@@ -26,7 +29,7 @@
<script defer data-domain="backers.webawesome.com" src="https://plausible.io/js/script.js"></script>
{# Web Awesome #}
<script type="module" src="/dist/webawesome.loader.js"></script>
<script type="module" src="/dist/webawesome.ssr-loader.js"></script>
<link rel="stylesheet" id="theme-stylesheet" href="/dist/themes/default.css" />
<link rel="stylesheet" href="/dist/themes/applied.css" />
<link id="color-stylesheet" rel="stylesheet" href="/dist/themes/color_standard.css" />
@@ -128,5 +131,6 @@
{% include 'search.njk' %}
</wa-page>
</body>
</html>

View File

@@ -0,0 +1,128 @@
/** TODO: This should probably get abstracted into an actual package. This is listens to the "lit-hydration-error" and then will add a button to show a dialog of the diff. */
(async () => {
const hostname = new URL(document.baseURI).hostname;
// Only diff on localhost. We dont need to show hydration errors on main site. Only locally.
if (hostname !== 'localhost') {
return;
}
const { diffLines } = await import('https://cdn.jsdelivr.net/npm/diff@5.2.0/+esm');
const { getDiffableHTML } = await import(
'https://cdn.jsdelivr.net/npm/@open-wc/semantic-dom-diff@0.20.1/get-diffable-html.js/+esm'
);
function wrap(el, wrapper) {
el.parentNode.insertBefore(wrapper, el);
wrapper.appendChild(el);
}
function handleLitHydrationError(e) {
const element = e.target;
const scratch = document.createElement('div');
const node = element.cloneNode(true);
scratch.append(node);
document.body.append(scratch);
customElements.upgrade(node);
node.updateComplete.then(() => {
// Render styles.
const elementStyles = element.constructor.elementStyles;
const finalStyles = [];
if (elementStyles !== undefined && elementStyles.length > 0) {
for (const style of elementStyles) {
finalStyles.push(style.cssText);
}
}
let innerHTML = scratch.firstElementChild?.shadowRoot.innerHTML;
if (finalStyles?.length) {
const styleTag = `<style>${finalStyles.join('\n')}</style>`;
innerHTML = styleTag + '\n' + innerHTML;
}
const clientHTML = getDiffableHTML(innerHTML);
const serverHTML = getDiffableHTML(element.shadowRoot?.innerHTML);
const diffDebugger = document.createElement('div');
diffDebugger.className = 'diff-debugger';
diffDebugger.innerHTML = `
<button class="diff-dialog-toggle">
Show Hydration Mismatch
</button>
<wa-dialog class="diff-dialog" with-header light-dismiss>
<div class="diff-grid">
<div>
<div>Server</div>
<pre class="diff-server"><code></code></pre>
</div>
<div>
<div>Client</div>
<pre class="diff-client"><code></code></pre>
</div>
<div>
<div>Diff</div>
<pre class="diff-viewer"><code></code></pre>
</div>
</div>
</wa-dialog>
`;
element.focus();
wrap(element, diffDebugger);
diffDebugger.querySelector('.diff-server > code').textContent = serverHTML;
diffDebugger.querySelector('.diff-client > code').textContent = clientHTML;
const diffViewer = diffDebugger.querySelector('.diff-viewer > code');
diffViewer.innerHTML = '';
diffViewer.appendChild(
createDiff({
serverHTML,
clientHTML
})
);
});
}
function createDiff({ serverHTML, clientHTML }) {
const diff = diffLines(serverHTML, clientHTML, {
ignoreWhitespace: false,
newLineIsToken: true
});
const fragment = document.createDocumentFragment();
for (var i = 0; i < diff.length; i++) {
if (diff[i].added && diff[i + 1] && diff[i + 1].removed) {
var swap = diff[i];
diff[i] = diff[i + 1];
diff[i + 1] = swap;
}
var node;
if (diff[i].removed) {
node = document.createElement('del');
node.appendChild(document.createTextNode(diff[i].value));
} else if (diff[i].added) {
node = document.createElement('ins');
node.appendChild(document.createTextNode(diff[i].value));
} else {
node = document.createTextNode(diff[i].value);
}
fragment.appendChild(node);
}
return fragment;
}
function handleDialogToggle(e) {
const button = e.composedPath().find(el => {
return el.classList && el.classList.contains('diff-dialog-toggle');
});
if (button) {
button.parentElement.querySelector('.diff-dialog').open = true;
}
}
document.addEventListener('lit-hydration-error', handleLitHydrationError);
document.addEventListener('click', handleDialogToggle);
})();

View File

@@ -43,6 +43,30 @@ function restoreScrollPosition(event) {
});
}
function fixDSD(e) {
const newElement = e.detail.newBody || e.detail.newFrame || e.detail.newStream;
if (!newElement) {
return;
}
// https://developer.chrome.com/docs/css-ui/declarative-shadow-dom#polyfill
(function attachShadowRoots(root) {
root.querySelectorAll('template[shadowrootmode]').forEach(template => {
const mode = template.getAttribute('shadowrootmode');
const shadowRoot = template.parentNode.attachShadow({ mode });
shadowRoot.appendChild(template.content);
template.remove();
attachShadowRoots(shadowRoot);
});
})(newElement);
}
// Fixes an issue with DSD keeping the `<template>` elements hanging around in the lightdom.
// https://github.com/hotwired/turbo/issues/1292
['turbo:before-render', 'turbo:before-stream-render', 'turbo:before-frame-render'].forEach(eventName => {
document.addEventListener(eventName, fixDSD);
});
window.addEventListener('turbo:before-cache', saveScrollPosition);
window.addEventListener('turbo:before-render', restoreScrollPosition);
window.addEventListener('turbo:render', restoreScrollPosition);

View File

@@ -19,15 +19,6 @@ wa-page {
--wa-flow-spacing: var(--wa-space-xl);
}
wa-page[view='desktop'] [data-toggle-nav] {
display: none;
}
wa-page[view='desktop'] .only-mobile,
wa-page:not([view='desktop']) .only-desktop {
display: none;
}
/* Header */
wa-page::part(header) {
background-color: var(--wa-color-surface-default);
@@ -92,19 +83,6 @@ wa-page > header {
}
}
/* Navigation sidebar */
wa-page[view='desktop']::part(navigation) {
border-right: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet);
}
wa-page[view='desktop'] > #sidebar {
overflow: auto;
max-height: 100%;
min-width: 300px;
padding: var(--wa-space-xl);
max-width: 300px;
}
#sidebar,
#outline {
h2 {
@@ -165,10 +143,6 @@ wa-page > main {
margin-inline: auto;
}
wa-page[view='desktop'] > main {
padding: var(--wa-space-3xl);
}
.component-info {
margin-block-end: var(--wa-flow-spacing);
}
@@ -274,3 +248,35 @@ wa-page[view='desktop'] > main {
.table-scroll {
overflow-x: auto;
}
/** mobile */
@media screen and (max-width: 768px) {
wa-page .only-desktop {
display: none;
}
}
/** desktop */
@media screen and not (max-width: 768px) {
wa-page [data-toggle-nav],
wa-page .only-mobile {
display: none;
}
/* Navigation sidebar */
wa-page::part(navigation) {
border-right: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet);
}
wa-page > #sidebar {
overflow: auto;
max-height: 100%;
min-width: 300px;
padding: var(--wa-space-xl);
max-width: 300px;
}
wa-page > main {
padding: var(--wa-space-3xl);
}
}

View File

@@ -0,0 +1,38 @@
/** TODO: This should probably get abstracted into an actual package. */
.diff-dialog-toggle {
position: absolute;
top: 0;
right: 0;
}
.diff-debugger {
position: relative;
border: 4px solid red;
}
.diff-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr);
max-height: 80vh;
gap: 16px;
}
.diff-grid > * {
height: 100%;
}
.diff-dialog::part(dialog) {
max-width: 90vw;
width: 90vw;
}
.diff-dialog ins {
text-decoration: none;
}
.diff-dialog pre {
border: 1px solid transparent;
height: 100%;
max-height: calc(100% - 2em);
}
.diff-dialog code {
height: 100%;
}
.diff-dialog del {
text-decoration: none;
}

View File

@@ -5,7 +5,7 @@ layout: component
---
```html {.example}
<form><wa-input></wa-input></form>
<wa-input></wa-input>
```
:::info

View File

@@ -52,7 +52,7 @@ Use the `readonly` attribute to display a rating that users can't change.
### Disabled
Use the `disable` attribute to disable the rating.
Use the `disabled` attribute to disable the rating.
```html {.example}
<wa-rating label="Rating" disabled value="3"></wa-rating>
@@ -110,8 +110,12 @@ You can provide custom icons by passing a function to the `getSymbol` property.
```html {.example}
<wa-rating label="Rating" class="rating-hearts" style="--symbol-color-active: #ff4136;"></wa-rating>
<script>
<script type="module">
const rating = document.querySelector('.rating-hearts');
await customElements.whenDefined("wa-rating")
await rating.updateComplete
rating.getSymbol = () => '<wa-icon name="heart" variant="solid"></wa-icon>';
</script>
```
@@ -123,9 +127,12 @@ You can also use the `getSymbol` property to render different icons based on val
```html {.example}
<wa-rating label="Rating" class="rating-emojis"></wa-rating>
<script>
<script type="module">
const rating = document.querySelector('.rating-emojis');
await customElements.whenDefined("wa-rating")
await rating.updateComplete
rating.getSymbol = value => {
const icons = ['face-angry', 'face-frown', 'face-meh', 'face-smile', 'face-laugh'];
return `<wa-icon name="${icons[value - 1]}"></wa-icon>`;

View File

@@ -0,0 +1,111 @@
---
title: Server Side Rendering
description: A document on how to get started with SSR in Web Awesome.
layout: page
---
## Caveats
SSR in Web Awesome is to be considered experimental. There are bugs and timing issues. There are things to iron out. Please bear with us. Part of the experimental status comes from Lit SSR being experimental, and all of our components originally being designed as client only.
## Adding hydration to the Frontend
If you're using the `webawesome.loader.js` file which automatically loads , make sure to change it to `webawesome.ssr-loader.js`
```diff
- <script type="module" src="https://early.webawesome.com/webawesome@3.0.0-alpha.2/dist/webawesome.loader.js"></script>
+ <script type="module" src="https://early.webawesome.com/webawesome@3.0.0-alpha.2/dist/webawesome.ssr-loader.js"></script>
```
If you're using a bundler:
```js
// Make sure this import is first.
import "@lit-labs/ssr-client/lit-element-hydrate-support.js"
import "webawesome/dist/components/button/button.js"
import "webawesome/dist/components/input/input.js"
```
## Server rendering
SSR on your backend is largely dependent on what backend you're using.
For docs on how to hook up your backend, checkout this document from Lit:
<https://lit.dev/docs/ssr/server-usage/>
For example, here's roughly what the 11ty integration looks like:
```js
// eleventy.config.js
import litPlugin from '@lit-labs/eleventy-plugin-lit';
eleventyConfig.addPlugin(litPlugin, {
mode: 'worker',
componentModules: [
"webawesome/dist/components/button/button.js",
"webawesome/dist/components/input/input.js"
]
});
```
## Hydration
All Web Awesome components that get SSR'ed will have `did-ssr=""` on them.
For example: `<wa-button did-ssr="">`
This can help if you need some styling prior to the element connecting.
## Timing issues
Before setting any properties on your frontend, it is important to first wait for the element to be defined, and then wait for its update to complete.
```js
const rating = document.querySelector("wa-rating")
// If we dont want for the component to be defined, and for the initial hydration to finish, we will get a hydration error from Lit.
await customElements.whenDefined("wa-rating")
await rating.updateComplete
rating.getSymbol = () => '<wa-icon name="heart" variant="solid"></wa-icon>';
```
## Usage with Turbo
Turbo, the Hotwire library, has an issue with SSR + declarative shadow dom. To fix this, you can add the following.
```js
function fixDeclarativeShadowDOM(e) {
const newElement = e.detail.newBody || e.detail.newFrame || e.detail.newStream;
if (!newElement) {
return;
}
// https://developer.chrome.com/docs/css-ui/declarative-shadow-dom#polyfill
(function attachShadowRoots(root) {
root.querySelectorAll('template[shadowrootmode]').forEach(template => {
const mode = template.getAttribute('shadowrootmode');
const shadowRoot = template.parentNode.attachShadow({ mode });
shadowRoot.appendChild(template.content);
template.remove();
attachShadowRoots(shadowRoot);
});
})(newElement);
}
// Fixes an issue with DSD keeping the `<template>` elements hanging around in the lightdom.
// https://github.com/hotwired/turbo/issues/1292
['turbo:before-render', 'turbo:before-stream-render', 'turbo:before-frame-render'].forEach(eventName => {
document.addEventListener(eventName, fixDeclarativeShadowDOM);
});
```
## Additional TODOs
- [ ] - `@shoelace-style/localize` (our localization library) has no way to set a language currently so it always falls back to `en`.
- [ ] - `<wa-icon>` has no fallback if there's no JS besides a blank `<svg>`. There's perhaps some backend mechanisms we can use to fetch. But requires altering APIs. Should also have a way to set height / widths, but we dont want to increase pain for SSR users.
- [ ] - `<wa-qr-code>` QR Code will not error on the backend and will render a blank canvas at the appropriate size, but will not render the canvas until the client component connects.
- [ ] - `setBasePath` and `kit codes` may need reconfiguring to work with SSR.

View File

@@ -14,6 +14,12 @@ During the alpha period, things might break! We take breaking changes very serio
## Next
- Fixed form controls to behave like their native counterparts for value and defaultValue properties / attributes respectively. [#157]
- Fixed a bug in `<wa-input>` around value attributes and properties to behave like native `<input>`. [#157]
- Added `scroll-margin-top` to children of `wa-page` [#157]
- Added `--scroll-margin-top` css variable `wa-page` [#157]
- Added SSR support to all components [#157]
- Fixed a bug in `<wa-checkbox>` where unchecking and then checking would "clear" its value. [#157]
- Fixed a bug where `<wa-relative-time>` would announce the full time instead of the relative time in screen readers [#22](https://github.com/shoelace-style/webawesome-alpha/issues/22)
- Fixed a bug in `<wa-tab-group>` in Firefox where the overflow container would keep focus. [#14](https://github.com/shoelace-style/webawesome-alpha/issues/14)
- Fixed a bug in `<wa-input>` where `minlength` and `maxlength` were not being properly validated. [#35](https://github.com/shoelace-style/webawesome-alpha/issues/35)

View File

@@ -373,3 +373,17 @@ Guidelines for writing tests:
- Try keeping the main test readable: Extract more complicated sets of selectors/commands/assertions into separate functions.
- Try to aim testing the user facing features of the component instead of the internal workings of the component.
- Group multiple tests for one feature into describe blocks.
### Running tests
Right now, tests run both "hydrated" (SSR -> client hydrated) and "client only". If you're debugging only one specific kind you can set an environment variable. For example, to run only the client tests, you can do:
```bash
CSR_ONLY="true" npm run test
```
or for hydrated rendering only:
```bash
SSR_ONLY="true" npm run test
```

View File

@@ -20,9 +20,14 @@ layout: page
margin: 0;
}
}
wa-page:not([view="desktop"]) > main {
--content-flow-spacing: 3rem;
/** this technically relies on insertion order. */
@media screen and (max-width: 768px) {
wa-page > main {
--content-flow-spacing: 3rem !important;
}
}
.brand-font {
font-family: cera-round-pro;
}

0
index.html Normal file
View File

417
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@ctrl/tinycolor": "^4.0.2",
"@floating-ui/dom": "^1.5.3",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.1.2",
"@shoelace-style/localize": "^3.2.1",
"composed-offset-position": "^0.0.4",
"lit": "^3.0.0",
"qr-creator": "^1.0.0"
@@ -20,6 +20,8 @@
"devDependencies": {
"@11ty/eleventy": "3.0.0-alpha.5",
"@custom-elements-manifest/analyzer": "^0.9.4",
"@lit-labs/eleventy-plugin-lit": "^1.0.3",
"@lit-labs/testing": "^0.2.4",
"@lit/react": "^1.0.0",
"@open-wc/testing": "^3.2.0",
"@types/mocha": "^10.0.2",
@@ -69,7 +71,7 @@
"node-html-parser": "^6.1.13",
"ora": "^8.0.1",
"pascal-case": "^3.1.2",
"playwright": "^1.42.0",
"playwright": "^1.46.1",
"plop": "^4.0.0",
"prettier": "^3.0.3",
"prismjs": "^1.29.0",
@@ -1571,10 +1573,223 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lit-labs/eleventy-plugin-lit": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lit-labs/eleventy-plugin-lit/-/eleventy-plugin-lit-1.0.3.tgz",
"integrity": "sha512-24824crd4P0D2Y6fyfO2c0SyfU9x0vpRwxabypmp3bTXrNrTOZflREATL57Qa9EEgirZvA/ervKy5Y0WCkjGag==",
"dev": true,
"dependencies": {
"@lit-labs/ssr": "^3.1.8",
"lit": "^2.7.0 || ^3.0.0"
},
"engines": {
"node": ">=12.16.0"
}
},
"node_modules/@lit-labs/ssr": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr/-/ssr-3.2.2.tgz",
"integrity": "sha512-He5TzeNPM9ECmVpgXRYmVlz0UA5YnzHlT43kyLi2Lu6mUidskqJVonk9W5K699+2DKhoXp8Ra4EJmHR6KrcW1Q==",
"dev": true,
"dependencies": {
"@lit-labs/ssr-client": "^1.1.7",
"@lit-labs/ssr-dom-shim": "^1.2.0",
"@lit/reactive-element": "^2.0.4",
"@parse5/tools": "^0.3.0",
"@types/node": "^16.0.0",
"enhanced-resolve": "^5.10.0",
"lit": "^3.1.2",
"lit-element": "^4.0.4",
"lit-html": "^3.1.2",
"node-fetch": "^3.2.8",
"parse5": "^7.1.1"
},
"engines": {
"node": ">=13.9.0"
}
},
"node_modules/@lit-labs/ssr-client": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-client/-/ssr-client-1.1.7.tgz",
"integrity": "sha512-VvqhY/iif3FHrlhkzEPsuX/7h/NqnfxLwVf0p8ghNIlKegRyRqgeaJevZ57s/u/LiFyKgqksRP5n+LmNvpxN+A==",
"dev": true,
"dependencies": {
"@lit/reactive-element": "^2.0.4",
"lit": "^3.1.2",
"lit-html": "^3.1.2"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz",
"integrity": "sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g=="
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.0.tgz",
"integrity": "sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g=="
},
"node_modules/@lit-labs/ssr/node_modules/@types/node": {
"version": "16.18.101",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz",
"integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==",
"dev": true
},
"node_modules/@lit-labs/ssr/node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"dev": true,
"engines": {
"node": ">= 12"
}
},
"node_modules/@lit-labs/ssr/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@lit-labs/ssr/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dev": true,
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@lit-labs/ssr/node_modules/parse5": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
"dev": true,
"dependencies": {
"entities": "^4.4.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/@lit-labs/testing": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@lit-labs/testing/-/testing-0.2.4.tgz",
"integrity": "sha512-NasNKbELasyfA1vIcfMwM0H/2mE98uFsyf/yDWtcl9fAEsTpRRWrmPdQDrHDyim5LKnsQutCzBP3Fof83hSCIA==",
"dev": true,
"dependencies": {
"@lit-labs/ssr": "^3.1.8",
"@lit-labs/ssr-client": "^1.1.4",
"@web/test-runner-commands": "^0.6.1",
"@webcomponents/template-shadowroot": "^0.1.0",
"lit": "^2.0.0 || ^3.0.0"
}
},
"node_modules/@lit-labs/testing/node_modules/@web/browser-logs": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-0.2.6.tgz",
"integrity": "sha512-CNjNVhd4FplRY8PPWIAt02vAowJAVcOoTNrR/NNb/o9pka7yI9qdjpWrWhEbPr2pOXonWb52AeAgdK66B8ZH7w==",
"dev": true,
"dependencies": {
"errorstacks": "^2.2.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@lit-labs/testing/node_modules/@web/test-runner-commands": {
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-0.6.6.tgz",
"integrity": "sha512-2DcK/+7f8QTicQpGFq/TmvKHDK/6Zald6rn1zqRlmj3pcH8fX6KHNVMU60Za9QgAKdorMBPfd8dJwWba5otzdw==",
"dev": true,
"dependencies": {
"@web/test-runner-core": "^0.10.29",
"mkdirp": "^1.0.4"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@lit-labs/testing/node_modules/@web/test-runner-core": {
"version": "0.10.29",
"resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.10.29.tgz",
"integrity": "sha512-0/ZALYaycEWswHhpyvl5yqo0uIfCmZe8q14nGPi1dMmNiqLcHjyFGnuIiLexI224AW74ljHcHllmDlXK9FUKGA==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.12.11",
"@types/babel__code-frame": "^7.0.2",
"@types/co-body": "^6.1.0",
"@types/convert-source-map": "^2.0.0",
"@types/debounce": "^1.2.0",
"@types/istanbul-lib-coverage": "^2.0.3",
"@types/istanbul-reports": "^3.0.0",
"@web/browser-logs": "^0.2.6",
"@web/dev-server-core": "^0.4.1",
"chokidar": "^3.4.3",
"cli-cursor": "^3.1.0",
"co-body": "^6.1.0",
"convert-source-map": "^2.0.0",
"debounce": "^1.2.0",
"dependency-graph": "^0.11.0",
"globby": "^11.0.1",
"ip": "^1.1.5",
"istanbul-lib-coverage": "^3.0.0",
"istanbul-lib-report": "^3.0.0",
"istanbul-reports": "^3.0.2",
"log-update": "^4.0.0",
"nanocolors": "^0.2.1",
"nanoid": "^3.1.25",
"open": "^8.0.2",
"picomatch": "^2.2.2",
"source-map": "^0.7.3"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@lit-labs/testing/node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.9",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@lit-labs/testing/node_modules/ip": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz",
"integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==",
"dev": true
},
"node_modules/@lit-labs/testing/node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@lit/react": {
"version": "1.0.2",
@@ -1586,11 +1801,11 @@
}
},
"node_modules/@lit/reactive-element": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.3.tgz",
"integrity": "sha512-e067EuTNNgOHm1tZcc0Ia7TCzD/9ZpoPegHKgesrGK6pSDRGkGDAQbYuQclqLPIoJ9eC8Kb9mYtGryWcM5AywA==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz",
"integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.2"
"@lit-labs/ssr-dom-shim": "^1.2.0"
}
},
"node_modules/@ljharb/through": {
@@ -1697,6 +1912,39 @@
"lit-html": "^2.0.0 || ^3.0.0"
}
},
"node_modules/@parse5/tools": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@parse5/tools/-/tools-0.3.0.tgz",
"integrity": "sha512-zxRyTHkqb7WQMV8kTNBKWb1BeOFUKXBXTBWuxg9H9hfvQB3IwP6Iw2U75Ia5eyRxPNltmY7E8YAlz6zWwUnjKg==",
"dev": true,
"dependencies": {
"parse5": "^7.0.0"
}
},
"node_modules/@parse5/tools/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@parse5/tools/node_modules/parse5": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
"dev": true,
"dependencies": {
"entities": "^4.4.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.1.0.tgz",
@@ -1976,9 +2224,9 @@
}
},
"node_modules/@shoelace-style/localize": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.1.2.tgz",
"integrity": "sha512-Hf45HeO+vdQblabpyZOTxJ4ZeZsmIUYXXPmoYrrR4OJ5OKxL+bhMz5mK8JXgl7HsoEowfz7+e248UGi861de9Q=="
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.1.tgz",
"integrity": "sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA=="
},
"node_modules/@sindresorhus/is": {
"version": "0.7.0",
@@ -3701,6 +3949,12 @@
"node": ">=8"
}
},
"node_modules/@webcomponents/template-shadowroot": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@webcomponents/template-shadowroot/-/template-shadowroot-0.1.0.tgz",
"integrity": "sha512-ry84Vft6xtRBbd4M/ptRodbOLodV5AD15TYhyRghCRgIcJJKmYmJ2v2BaaWxygENwh6Uq3zTfGPmlckKT/GXsQ==",
"dev": true
},
"node_modules/a-sync-waterfall": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz",
@@ -6189,9 +6443,9 @@
"dev": true
},
"node_modules/diff": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz",
"integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"dev": true,
"engines": {
"node": ">=0.3.1"
@@ -6591,6 +6845,19 @@
}
}
},
"node_modules/enhanced-resolve": {
"version": "5.17.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz",
"integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
@@ -7638,6 +7905,29 @@
"pend": "~1.2.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/figures": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz",
@@ -7893,6 +8183,18 @@
"node": ">=0.10.0"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dev": true,
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -10284,29 +10586,29 @@
}
},
"node_modules/lit": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.1.1.tgz",
"integrity": "sha512-hF1y4K58+Gqrz+aAPS0DNBwPqPrg6P04DuWK52eMkt/SM9Qe9keWLcFgRcEKOLuDlRZlDsDbNL37Vr7ew1VCuw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.2.0.tgz",
"integrity": "sha512-s6tI33Lf6VpDu7u4YqsSX78D28bYQulM+VAzsGch4fx2H0eLZnJsUBsPWmGYSGoKDNbjtRv02rio1o+UdPVwvw==",
"dependencies": {
"@lit/reactive-element": "^2.0.0",
"lit-element": "^4.0.0",
"lit-html": "^3.1.0"
"@lit/reactive-element": "^2.0.4",
"lit-element": "^4.1.0",
"lit-html": "^3.2.0"
}
},
"node_modules/lit-element": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.3.tgz",
"integrity": "sha512-2vhidmC7gGLfnVx41P8UZpzyS0Fb8wYhS5RCm16cMW3oERO0Khd3EsKwtRpOnttuByI5rURjT2dfoA7NlInCNw==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.0.tgz",
"integrity": "sha512-gSejRUQJuMQjV2Z59KAS/D4iElUhwKpIyJvZ9w+DIagIQjfJnhR20h2Q5ddpzXGS+fF0tMZ/xEYGMnKmaI/iww==",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.2",
"@lit/reactive-element": "^2.0.0",
"lit-html": "^3.1.0"
"@lit-labs/ssr-dom-shim": "^1.2.0",
"@lit/reactive-element": "^2.0.4",
"lit-html": "^3.2.0"
}
},
"node_modules/lit-html": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.1.tgz",
"integrity": "sha512-x/EwfGk2D/f4odSFM40hcGumzqoKv0/SUh6fBO+1Ragez81APrcAMPo1jIrCDd9Sn+Z4CT867HWKViByvkDZUA==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.0.tgz",
"integrity": "sha512-pwT/HwoxqI9FggTrYVarkBKFN9MlTUpLrDHubTmW4SrkL3kkqW5gxwbxMMUnbbRHBC0WTZnYHcjDSCM559VyfA==",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
@@ -11442,6 +11744,25 @@
"tslib": "^2.0.3"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -12449,33 +12770,33 @@
}
},
"node_modules/playwright": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0.tgz",
"integrity": "sha512-Ko7YRUgj5xBHbntrgt4EIw/nE//XBHOKVKnBjO1KuZkmkhlbgyggTe5s9hjqQ1LpN+Xg+kHsQyt5Pa0Bw5XpvQ==",
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz",
"integrity": "sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==",
"dev": true,
"dependencies": {
"playwright-core": "1.42.0"
"playwright-core": "1.46.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0.tgz",
"integrity": "sha512-0HD9y8qEVlcbsAjdpBaFjmaTHf+1FeIddy8VJLeiqwhcNqGCBe4Wp2e8knpqiYbzxtxarxiXyNDw2cG8sCaNMQ==",
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz",
"integrity": "sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
@@ -14480,6 +14801,15 @@
"node": ">=12.17"
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz",
@@ -15068,6 +15398,15 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"dev": true,
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@@ -66,7 +66,7 @@
"@ctrl/tinycolor": "^4.0.2",
"@floating-ui/dom": "^1.5.3",
"@shoelace-style/animations": "^1.1.0",
"@shoelace-style/localize": "^3.1.2",
"@shoelace-style/localize": "^3.2.1",
"composed-offset-position": "^0.0.4",
"lit": "^3.0.0",
"qr-creator": "^1.0.0"
@@ -74,6 +74,8 @@
"devDependencies": {
"@11ty/eleventy": "3.0.0-alpha.5",
"@custom-elements-manifest/analyzer": "^0.9.4",
"@lit-labs/eleventy-plugin-lit": "^1.0.3",
"@lit-labs/testing": "^0.2.4",
"@lit/react": "^1.0.0",
"@open-wc/testing": "^3.2.0",
"@types/mocha": "^10.0.2",
@@ -123,7 +125,7 @@
"node-html-parser": "^6.1.13",
"ora": "^8.0.1",
"pascal-case": "^3.1.2",
"playwright": "^1.42.0",
"playwright": "^1.46.1",
"plop": "^4.0.0",
"prettier": "^3.0.3",
"prismjs": "^1.29.0",
@@ -136,6 +138,9 @@
"user-agent-data-types": "^0.3.1",
"uuid": "^9.0.1"
},
"overrides": {
"playwright": "^1.46.1"
},
"lint-staged": {
"*.{ts,js}": [
"eslint --max-warnings 0 --cache --fix",

View File

@@ -1,6 +1,6 @@
import { deleteAsync } from 'del';
import { dirname, join, relative } from 'path';
import { distDir, docsDir, rootDir, runScript, siteDir } from './utils.js';
import { distDir, docsDir, cdnDir, rootDir, runScript, siteDir } from './utils.js';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { globby } from 'globby';
@@ -14,17 +14,16 @@ import getPort, { portNumbers } from 'get-port';
import ora from 'ora';
import process from 'process';
//
// TODO - CDN dist and unbundled dist
//
const __dirname = dirname(fileURLToPath(import.meta.url));
const isDeveloping = process.argv.includes('--develop');
const isAlpha = process.argv.includes('--alpha');
const spinner = ora({ text: 'Web Awesome', color: 'cyan' }).start();
const packageData = JSON.parse(await readFile(join(rootDir, 'package.json'), 'utf-8'));
const version = JSON.stringify(packageData.version.toString());
let buildContext;
let buildContexts = {
bundledContext: {},
unbundledContext: {}
};
/**
* Runs the full build.
@@ -38,6 +37,10 @@ async function buildAll() {
await generateReactWrappers();
await generateTypes();
await generateStyles();
// copy everything to unbundled before we generate bundles.
await copy(cdnDir, distDir, { overwrite: true });
await generateBundle();
await generateDocs();
@@ -54,7 +57,9 @@ async function cleanup() {
spinner.start('Cleaning up dist');
await deleteAsync(distDir);
await deleteAsync(cdnDir);
await mkdir(distDir, { recursive: true });
await mkdir(cdnDir, { recursive: true });
spinner.succeed();
}
@@ -83,7 +88,7 @@ function generateReactWrappers() {
spinner.start('Generating React wrappers');
try {
execSync(`node scripts/make-react.js --outdir "${distDir}"`, { stdio: 'inherit' });
execSync(`node scripts/make-react.js --outdir "${cdnDir}"`, { stdio: 'inherit' });
} catch (error) {
console.error(`\n\n${error.message}`);
}
@@ -100,13 +105,13 @@ async function generateStyles() {
// NOTE - alpha setting omits all stylesheets except for these because we use them in the docs
if (isAlpha) {
await copy(join(rootDir, 'src/themes/applied.css'), join(distDir, 'themes/applied.css'), { overwrite: true });
await copy(join(rootDir, 'src/themes/color_standard.css'), join(distDir, 'themes/color_standard.css'), {
await copy(join(rootDir, 'src/themes/applied.css'), join(cdnDir, 'themes/applied.css'), { overwrite: true });
await copy(join(rootDir, 'src/themes/color_standard.css'), join(cdnDir, 'themes/color_standard.css'), {
overwrite: true
});
await copy(join(rootDir, 'src/themes/default.css'), join(distDir, 'themes/default.css'), { overwrite: true });
await copy(join(rootDir, 'src/themes/default.css'), join(cdnDir, 'themes/default.css'), { overwrite: true });
} else {
await copy(join(rootDir, 'src/themes'), join(distDir, 'themes'), { overwrite: true });
await copy(join(rootDir, 'src/themes'), join(cdnDir, 'themes'), { overwrite: true });
}
spinner.succeed();
@@ -121,7 +126,7 @@ async function generateTypes() {
spinner.start('Running the TypeScript compiler');
try {
execSync(`tsc --project ./tsconfig.prod.json --outdir "${distDir}"`);
execSync(`tsc --project ./tsconfig.prod.json --outdir "${cdnDir}"`);
} catch (error) {
return Promise.reject(error.stdout);
}
@@ -137,6 +142,7 @@ async function generateTypes() {
async function generateBundle() {
spinner.start('Bundling with esbuild');
// Bundled config
const config = {
format: 'esm',
target: 'es2020',
@@ -148,6 +154,7 @@ async function generateBundle() {
'./src/webawesome.ts',
// Autoloader + utilities
'./src/webawesome.loader.ts',
'./src/webawesome.ssr-loader.ts',
// Individual components
...(await globby('./src/components/**/!(*.(style|test)).ts')),
// Translations
@@ -155,23 +162,41 @@ async function generateBundle() {
// React wrappers
...(await globby('./src/react/**/*.ts'))
],
outdir: distDir,
outdir: cdnDir,
chunkNames: 'chunks/[name].[hash]',
define: {
'process.env.NODE_ENV': '"production"' // required by Floating UI
},
bundle: true,
splitting: true,
minify: false,
plugins: [replace({ __WEBAWESOME_VERSION__: version })]
};
if (isDeveloping) {
// Incremental builds for dev
buildContext = await esbuild.context(config);
await buildContext.rebuild();
} else {
// One-time build for production
await esbuild.build(config);
const unbundledConfig = {
...config,
splitting: true,
treeShaking: true,
// Don't inline libraries like Lit etc.
packages: 'external',
outdir: distDir
};
try {
if (isDeveloping) {
buildContexts.bundledContext = await esbuild.context(config);
buildContexts.unbundledContext = await esbuild.context(unbundledConfig);
await buildContexts.bundledContext.rebuild();
await buildContexts.unbundledContext.rebuild();
} else {
// One-time build for production
await esbuild.build(config);
await esbuild.build(unbundledConfig);
}
} catch (error) {
spinner.fail();
console.log(chalk.red(`\n${error}`));
}
spinner.succeed();
@@ -183,7 +208,8 @@ async function generateBundle() {
async function regenerateBundle() {
try {
spinner.start('Re-bundling with esbuild');
await buildContext.rebuild();
await buildContexts.bundledContext.rebuild();
await buildContexts.unbundledContext.rebuild();
} catch (error) {
spinner.fail();
console.log(chalk.red(`\n${error}`));
@@ -216,7 +242,7 @@ async function generateDocs() {
// Copy dist (production only)
if (!isDeveloping) {
await copy(distDir, join(siteDir, 'dist'));
await copy(cdnDir, join(siteDir, 'dist'));
}
spinner.succeed(`Writing the docs ${chalk.gray(`(${output}`)})`);
@@ -256,7 +282,7 @@ if (isDeveloping) {
server: {
baseDir: siteDir,
routes: {
'/dist': './dist'
'/dist/': './dist-cdn/'
}
},
callbacks: {
@@ -330,9 +356,8 @@ if (isDeveloping) {
// Cleanup everything when the process terminates
//
function terminate() {
if (buildContext) {
buildContext.dispose();
}
// dispose of contexts.
Object.values(buildContexts).forEach(context => context?.dispose?.());
if (spinner) {
spinner.stop();

View File

@@ -7,6 +7,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// Helpful directories
export const rootDir = dirname(__dirname);
export const distDir = join(rootDir, 'dist');
export const cdnDir = join(rootDir, 'dist-cdn');
export const docsDir = join(rootDir, 'docs');
export const siteDir = join(rootDir, '_site');

View File

@@ -1,67 +1,72 @@
import { clickOnElement } from '../../internal/test.js';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { clientFixture } from '../../internal/test/fixture.js';
import { expect, oneEvent } from '@open-wc/testing';
import { html } from 'lit';
import type WaAnimatedImage from './animated-image.js';
describe('<wa-animated-image>', () => {
it('should render a component', async () => {
const animatedImage = await fixture(html` <wa-animated-image></wa-animated-image> `);
// @TODO: Figure out why hydrated tests are failing
for (const fixture of [clientFixture]) {
it('should render a component', async () => {
const animatedImage = await fixture(html` <wa-animated-image></wa-animated-image> `);
expect(animatedImage).to.exist;
});
it('should render be accessible', async () => {
const animatedImage = await fixture(html` <wa-animated-image></wa-animated-image> `);
await expect(animatedImage).to.be.accessible();
});
const files = ['docs/assets/images/walk.gif', 'docs/assets/images/tie.webp'];
files.forEach((file: string) => {
it(`should load a ${file} without errors`, async () => {
const animatedImage = await fixture<WaAnimatedImage>(html` <wa-animated-image></wa-animated-image> `);
let errorCount = 0;
oneEvent(animatedImage, 'wa-error').then(() => errorCount++);
await loadImage(animatedImage, file);
expect(errorCount).to.be.equal(0);
expect(animatedImage).to.exist;
});
it(`should play ${file} on click`, async () => {
const animatedImage = await fixture<WaAnimatedImage>(html` <wa-animated-image></wa-animated-image> `);
await loadImage(animatedImage, file);
it('should render be accessible', async () => {
const animatedImage = await fixture(html` <wa-animated-image></wa-animated-image> `);
expect(animatedImage.play).not.to.be.true;
await clickOnElement(animatedImage);
expect(animatedImage.play).to.be.true;
await expect(animatedImage).to.be.accessible();
});
it(`should pause and resume ${file} on click`, async () => {
const animatedImage = await fixture<WaAnimatedImage>(html` <wa-animated-image></wa-animated-image> `);
await loadImage(animatedImage, file);
const files = ['docs/assets/images/walk.gif', 'docs/assets/images/tie.webp'];
animatedImage.play = true;
files.forEach((file: string) => {
it(`should load a ${file} without errors`, async () => {
const animatedImage = await fixture<WaAnimatedImage>(html` <wa-animated-image></wa-animated-image> `);
let errorCount = 0;
oneEvent(animatedImage, 'wa-error').then(() => errorCount++);
await loadImage(animatedImage, file);
await clickOnElement(animatedImage);
expect(errorCount).to.be.equal(0);
});
expect(animatedImage.play).to.be.false;
it(`should play ${file} on click`, async () => {
const animatedImage = await fixture<WaAnimatedImage>(html` <wa-animated-image></wa-animated-image> `);
await loadImage(animatedImage, file);
await clickOnElement(animatedImage);
expect(animatedImage.play).not.to.be.true;
expect(animatedImage.play).to.be.true;
await clickOnElement(animatedImage);
expect(animatedImage.play).to.be.true;
});
it(`should pause and resume ${file} on click`, async () => {
const animatedImage = await fixture<WaAnimatedImage>(html` <wa-animated-image></wa-animated-image> `);
await loadImage(animatedImage, file);
animatedImage.play = true;
await clickOnElement(animatedImage);
expect(animatedImage.play).to.be.false;
await clickOnElement(animatedImage);
expect(animatedImage.play).to.be.true;
});
});
});
it('should emit an error event on invalid url', async () => {
const animatedImage = await fixture<WaAnimatedImage>(html` <wa-animated-image></wa-animated-image> `);
it('should emit an error event on invalid url', async () => {
const animatedImage = await fixture<WaAnimatedImage>(html` <wa-animated-image></wa-animated-image> `);
const errorPromise = oneEvent(animatedImage, 'wa-error');
animatedImage.src = 'completelyWrong';
const errorPromise = oneEvent(animatedImage, 'wa-error');
animatedImage.src = 'completelyWrong';
await errorPromise;
});
await errorPromise;
});
}
});
async function loadImage(animatedImage: WaAnimatedImage, file: string) {
const loadingPromise = oneEvent(animatedImage, 'wa-load');

View File

@@ -1,82 +1,105 @@
import { aTimeout, expect, fixture, oneEvent } from '@open-wc/testing';
import { aTimeout, expect, oneEvent } from '@open-wc/testing';
import {
clientFixture
// hydratedFixture
} from '../../internal/test/fixture.js';
import { html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import type WaAnimation from './animation.js';
describe('<wa-animation>', () => {
const boxToAnimate = html`<div style="width: 10px; height: 10px;" data-testid="animated-box"></div>`;
// Don't use HTML because its not supported by Lit SSR for WTR.
// https://github.com/lit/lit/issues/4739#issuecomment-2299899990
const boxToAnimate = `<div style="width: 10px; height: 10px;" data-testid="animated-box"></div>`;
it('renders', async () => {
const animationContainer = await fixture<WaAnimation>(html`<wa-animation>${boxToAnimate}</wa-animation>`);
// Figure out why hydratedFixture fails promises.
for (const fixture of [clientFixture]) {
describe(`with "${fixture.type}" rendering`, () => {
it('renders', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation>${unsafeHTML(boxToAnimate)}</wa-animation>`
);
expect(animationContainer).to.exist;
});
expect(animationContainer).to.exist;
});
it('is accessible', async () => {
const animationContainer = await fixture<WaAnimation>(html`<wa-animation>${boxToAnimate}</wa-animation>`);
it('is accessible', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation>${unsafeHTML(boxToAnimate)}</wa-animation>`
);
await expect(animationContainer).to.be.accessible();
});
await expect(animationContainer).to.be.accessible();
});
describe('animation start', () => {
it('does not start the animation by default', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="10">${boxToAnimate}</wa-animation>`
);
await aTimeout(0);
describe('animation start', () => {
it('does not start the animation by default', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="10"
>${unsafeHTML(boxToAnimate)}</wa-animation
>`
);
await aTimeout(0);
expect(animationContainer.play).to.be.false;
expect(animationContainer.play).to.be.false;
});
it('emits the correct event on animation start', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="10"
>${unsafeHTML(boxToAnimate)}</wa-animation
>`
);
const startPromise = oneEvent(animationContainer, 'wa-start');
animationContainer.play = true;
const isSettled = (await Promise.allSettled([startPromise]))[0].status === 'fulfilled';
expect(isSettled).to.equal(true);
});
});
it('emits the correct event on animation end', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="1">${unsafeHTML(boxToAnimate)}</wa-animation>`
);
const endPromise = oneEvent(animationContainer, 'wa-finish');
animationContainer.iterations = 1;
animationContainer.play = true;
return endPromise;
});
it('can be finished by hand', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="1000"
>${unsafeHTML(boxToAnimate)}</wa-animation
>`
);
const endPromise = oneEvent(animationContainer, 'wa-finish');
animationContainer.iterations = 1;
animationContainer.play = true;
await aTimeout(0);
animationContainer.finish();
return endPromise;
});
it('can be cancelled', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="1">${unsafeHTML(boxToAnimate)}</wa-animation>`
);
let animationHasFinished = false;
oneEvent(animationContainer, 'wa-finish').then(() => (animationHasFinished = true));
const cancelPromise = oneEvent(animationContainer, 'wa-cancel');
animationContainer.play = true;
await aTimeout(0);
animationContainer.cancel();
await cancelPromise;
expect(animationHasFinished).to.be.false;
});
});
it('emits the correct event on animation start', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="10">${boxToAnimate}</wa-animation>`
);
const startPromise = oneEvent(animationContainer, 'wa-start');
animationContainer.play = true;
return startPromise;
});
});
it('emits the correct event on animation end', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="1">${boxToAnimate}</wa-animation>`
);
const endPromise = oneEvent(animationContainer, 'wa-finish');
animationContainer.iterations = 1;
animationContainer.play = true;
return endPromise;
});
it('can be finished by hand', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="1000">${boxToAnimate}</wa-animation>`
);
const endPromise = oneEvent(animationContainer, 'wa-finish');
animationContainer.iterations = 1;
animationContainer.play = true;
await aTimeout(0);
animationContainer.finish();
return endPromise;
});
it('can be cancelled', async () => {
const animationContainer = await fixture<WaAnimation>(
html`<wa-animation name="bounce" easing="ease-in-out" duration="1">${boxToAnimate}</wa-animation>`
);
let animationHasFinished = false;
oneEvent(animationContainer, 'wa-finish').then(() => (animationHasFinished = true));
const cancelPromise = oneEvent(animationContainer, 'wa-cancel');
animationContainer.play = true;
await aTimeout(0);
animationContainer.cancel();
await cancelPromise;
expect(animationHasFinished).to.be.false;
});
}
});

View File

@@ -1,4 +1,6 @@
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaAvatar from './avatar.js';
// The default avatar background just misses AA contrast, but the next step up is way too dark. Since avatars aren't
@@ -8,168 +10,177 @@ const ignoredRules = ['color-contrast'];
describe('<wa-avatar>', () => {
let el: WaAvatar;
describe('when provided no parameters', () => {
before(async () => {
el = await fixture<WaAvatar>(html` <wa-avatar label="Avatar"></wa-avatar> `);
});
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when provided no parameters', () => {
beforeEach(async () => {
el = await fixture<WaAvatar>(html` <wa-avatar label="Avatar"></wa-avatar> `);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('should default to circle styling', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(el.getAttribute('shape')).to.eq('circle');
expect(part.classList.value.trim()).to.eq('avatar avatar--circle');
});
});
describe('when provided an image and label parameter', () => {
const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const label = 'Small transparent square';
before(async () => {
el = await fixture<WaAvatar>(html`<wa-avatar image="${image}" label="${label}"></wa-avatar>`);
});
it('should pass accessibility tests', async () => {
/**
* The image element itself is ancillary, because it's parent container contains the
* aria-label which dictates what "wa-avatar" is. This also implies that label text will
* resolve to "" when not provided and ignored by readers. This is why we use alt="" on
* the image element to pass accessibility.
* https://html.spec.whatwg.org/multipage/images.html#ancillary-images
*/
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "image" part, with src and a role of presentation', () => {
const part = el.shadowRoot!.querySelector('[part~="image"]')!;
expect(part.getAttribute('src')).to.eq(image);
});
it('renders the label attribute in the "base" part', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.getAttribute('aria-label')).to.eq(label);
});
});
describe('when provided initials parameter', () => {
const initials = 'SL';
before(async () => {
el = await fixture<WaAvatar>(html`<wa-avatar initials="${initials}" label="Avatar"></wa-avatar>`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "initials" part, with initials as the text node', () => {
const part = el.shadowRoot!.querySelector<HTMLElement>('[part~="initials"]')!;
expect(part.innerText).to.eq(initials);
});
});
describe('when image is present, the initials or icon part should not render', () => {
const initials = 'SL';
const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const label = 'Small transparent square';
before(async () => {
el = await fixture<WaAvatar>(
html`<wa-avatar image="${image}" label="${label}" initials="${initials}"></wa-avatar>`
);
});
it('should pass accessibility tests', async () => {
/**
* The image element itself is ancillary, because it's parent container contains the
* aria-label which dictates what "wa-avatar" is. This also implies that label text will
* resolve to "" when not provided and ignored by readers. This is why we use alt="" on
* the image element to pass accessibility.
* https://html.spec.whatwg.org/multipage/images.html#ancillary-images
*/
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "image" part, with src and a role of presentation', () => {
const part = el.shadowRoot!.querySelector('[part~="image"]')!;
expect(part.getAttribute('src')).to.eq(image);
});
it('should not render the initials part', () => {
const part = el.shadowRoot!.querySelector<HTMLElement>('[part~="initials"]')!;
expect(part).to.not.exist;
});
it('should not render the icon part', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=icon]')!;
expect(slot).to.not.exist;
});
});
['square', 'rounded', 'circle'].forEach(shape => {
describe(`when passed a shape attribute ${shape}`, () => {
before(async () => {
el = await fixture<WaAvatar>(html`<wa-avatar shape="${shape}" label="Shaped avatar"></wa-avatar>`);
it('should default to circle styling', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(el.getAttribute('shape')).to.eq('circle');
expect(part.classList.value.trim()).to.eq('avatar avatar--circle');
});
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
describe('when provided an image and label parameter', () => {
const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const label = 'Small transparent square';
beforeEach(async () => {
el = await fixture<WaAvatar>(html`<wa-avatar image="${image}" label="${label}"></wa-avatar>`);
});
it('should pass accessibility tests', async () => {
/**
* The image element itself is ancillary, because it's parent container contains the
* aria-label which dictates what "wa-avatar" is. This also implies that label text will
* resolve to "" when not provided and ignored by readers. This is why we use alt="" on
* the image element to pass accessibility.
* https://html.spec.whatwg.org/multipage/images.html#ancillary-images
*/
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "image" part, with src and a role of presentation', () => {
const part = el.shadowRoot!.querySelector('[part~="image"]')!;
expect(part.getAttribute('src')).to.eq(image);
});
it('renders the label attribute in the "base" part', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.getAttribute('aria-label')).to.eq(label);
});
});
it('appends the appropriate class on the "base" part', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
describe('when provided initials parameter', () => {
const initials = 'SL';
beforeEach(async () => {
el = await fixture<WaAvatar>(html`<wa-avatar initials="${initials}" label="Avatar"></wa-avatar>`);
});
expect(el.getAttribute('shape')).to.eq(shape);
expect(part.classList.value.trim()).to.eq(`avatar avatar--${shape}`);
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "initials" part, with initials as the text node', () => {
const part = el.shadowRoot!.querySelector<HTMLElement>('[part~="initials"]')!;
expect(part.innerText).to.eq(initials);
});
});
describe('when image is present, the initials or icon part should not render', () => {
const initials = 'SL';
const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const label = 'Small transparent square';
beforeEach(async () => {
el = await fixture<WaAvatar>(
html`<wa-avatar image="${image}" label="${label}" initials="${initials}"></wa-avatar>`
);
});
it('should pass accessibility tests', async () => {
/**
* The image element itself is ancillary, because it's parent container contains the
* aria-label which dictates what "wa-avatar" is. This also implies that label text will
* resolve to "" when not provided and ignored by readers. This is why we use alt="" on
* the image element to pass accessibility.
* https://html.spec.whatwg.org/multipage/images.html#ancillary-images
*/
await expect(el).to.be.accessible({ ignoredRules });
});
it('renders "image" part, with src and a role of presentation', () => {
const part = el.shadowRoot!.querySelector('[part~="image"]')!;
expect(part.getAttribute('src')).to.eq(image);
});
it('should not render the initials part', () => {
const part = el.shadowRoot!.querySelector<HTMLElement>('[part~="initials"]')!;
expect(part).to.not.exist;
});
it('should not render the icon part', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=icon]')!;
expect(slot).to.not.exist;
});
});
['square', 'rounded', 'circle'].forEach(shape => {
describe(`when passed a shape attribute ${shape}`, () => {
beforeEach(async () => {
el = await fixture<WaAvatar>(html`<wa-avatar shape="${shape}" label="Shaped avatar"></wa-avatar>`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('appends the appropriate class on the "base" part', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(el.getAttribute('shape')).to.eq(shape);
expect(part.classList.value.trim()).to.eq(`avatar avatar--${shape}`);
});
});
});
describe('when passed a <span>, on slot "icon"', () => {
beforeEach(async () => {
el = await fixture<WaAvatar>(
html`<wa-avatar label="Avatar"><span slot="icon">random content</span></wa-avatar>`
);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('should accept as an assigned child in the shadow root', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=icon]')!;
const childNodes = slot.assignedNodes({ flatten: true }) as HTMLElement[];
expect(childNodes.length).to.eq(1);
const span = childNodes[0];
expect(span.innerHTML).to.eq('random content');
});
});
it('should not render the image when the image fails to load', async () => {
el = await fixture<WaAvatar>(html`<wa-avatar></wa-avatar>`);
el.image = 'bad_image';
await aTimeout(0);
await el.updateComplete;
await waitUntil(() => el.shadowRoot!.querySelector('img') === null);
expect(el.shadowRoot!.querySelector('img')).to.be.null;
});
it('should show a valid image after being passed an invalid image initially', async () => {
el = await fixture<WaAvatar>(html`<wa-avatar></wa-avatar>`);
await aTimeout(0);
await el.updateComplete;
// await waitUntil(() => el.shadowRoot!.querySelector('img') === null);
expect(el.shadowRoot!.querySelector('img')).to.be.null;
el.image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
await el.updateComplete;
expect(el.shadowRoot?.querySelector('img')).to.exist;
});
});
});
describe('when passed a <span>, on slot "icon"', () => {
before(async () => {
el = await fixture<WaAvatar>(html`<wa-avatar label="Avatar"><span slot="icon">random content</span></wa-avatar>`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('should accept as an assigned child in the shadow root', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=icon]')!;
const childNodes = slot.assignedNodes({ flatten: true }) as HTMLElement[];
expect(childNodes.length).to.eq(1);
const span = childNodes[0];
expect(span.innerHTML).to.eq('random content');
});
});
it('should not render the image when the image fails to load', async () => {
el = await fixture<WaAvatar>(html`<wa-avatar></wa-avatar>`);
el.image = 'bad_image';
await aTimeout(0);
await waitUntil(() => el.shadowRoot!.querySelector('img') === null);
expect(el.shadowRoot!.querySelector('img')).to.be.null;
});
it('should show a valid image after being passed an invalid image initially', async () => {
el = await fixture<WaAvatar>(html`<wa-avatar></wa-avatar>`);
await aTimeout(0);
await waitUntil(() => el.shadowRoot!.querySelector('img') === null);
el.image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
await el.updateComplete;
expect(el.shadowRoot?.querySelector('img')).to.exist;
});
}
});

View File

@@ -1,4 +1,6 @@
import { expect, fixture, html } from '@open-wc/testing';
import { aTimeout, expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaBadge from './badge.js';
// The default badge background just misses AA contrast, but the next step up is way too dark. We're going to relax this
@@ -6,74 +8,70 @@ import type WaBadge from './badge.js';
const ignoredRules = ['color-contrast'];
describe('<wa-badge>', () => {
let el: WaBadge;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when provided no parameters', () => {
it('should render the child content provided', async () => {
const el = await fixture<WaBadge>(html` <wa-badge>Badge</wa-badge> `);
expect(el.innerText).to.eq('Badge');
});
describe('when provided no parameters', () => {
before(async () => {
el = await fixture<WaBadge>(html` <wa-badge>Badge</wa-badge> `);
});
it('should pass accessibility tests with a role of status on the base part.', async () => {
const el = await fixture<WaBadge>(html` <wa-badge>Badge</wa-badge> `);
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.getAttribute('role')).to.eq('status');
await expect(el).to.be.accessible({ ignoredRules });
});
it('should pass accessibility tests with a role of status on the base part.', async () => {
await expect(el).to.be.accessible({ ignoredRules });
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.getAttribute('role')).to.eq('status');
});
it('should render the child content provided', () => {
expect(el.innerText).to.eq('Badge');
});
it('should default to square styling, with the brand color', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.classList.value.trim()).to.eq('badge badge--brand');
});
});
describe('when provided a pill parameter', () => {
before(async () => {
el = await fixture<WaBadge>(html` <wa-badge pill>Badge</wa-badge> `);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('should append the pill class to the classlist to render a pill', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.classList.value.trim()).to.eq('badge badge--brand badge--pill');
});
});
describe('when provided a pulse parameter', () => {
before(async () => {
el = await fixture<WaBadge>(html` <wa-badge pulse>Badge</wa-badge> `);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('should append the pulse class to the classlist to render a pulse', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.classList.value.trim()).to.eq('badge badge--brand badge--pulse');
});
});
['brand', 'success', 'neutral', 'warning', 'danger'].forEach(variant => {
describe(`when passed a variant attribute ${variant}`, () => {
before(async () => {
el = await fixture<WaBadge>(html`<wa-badge variant="${variant}">Badge</wa-badge>`);
it('should default to square styling, with the brand color', async () => {
const el = await fixture<WaBadge>(html` <wa-badge>Badge</wa-badge> `);
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.classList.value.trim()).to.eq('badge badge--brand');
});
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
describe('when provided a pill parameter', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBadge>(html` <wa-badge pill>Badge</wa-badge> `);
await expect(el).to.be.accessible({ ignoredRules });
});
it('should append the pill class to the classlist to render a pill', async () => {
const el = await fixture<WaBadge>(html` <wa-badge pill>Badge</wa-badge> `);
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.classList.value.trim()).to.eq('badge badge--brand badge--pill');
});
});
it('should default to square styling, with the correct color', () => {
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.classList.value.trim()).to.eq(`badge badge--${variant}`);
describe('when provided a pulse parameter', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBadge>(html` <wa-badge pulse>Badge</wa-badge> `);
await expect(el).to.be.accessible({ ignoredRules });
await aTimeout(1);
});
it('should append the pulse class to the classlist to render a pulse', async () => {
const el = await fixture<WaBadge>(html` <wa-badge pulse>Badge</wa-badge> `);
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.classList.value.trim()).to.eq('badge badge--brand badge--pulse');
});
});
['brand', 'success', 'neutral', 'warning', 'danger'].forEach(variant => {
describe(`when passed a variant attribute ${variant}`, () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBadge>(html`<wa-badge variant="${variant}">Badge</wa-badge>`);
await expect(el).to.be.accessible({ ignoredRules });
await aTimeout(1);
});
it('should default to square styling, with the correct color', async () => {
const el = await fixture<WaBadge>(html`<wa-badge variant="${variant}">Badge</wa-badge>`);
const part = el.shadowRoot!.querySelector('[part~="base"]')!;
expect(part.classList.value.trim()).to.eq(`badge badge--${variant}`);
});
});
});
});
});
}
});

View File

@@ -1,172 +1,201 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaBreadcrumbItem from './breadcrumb-item.js';
describe('<wa-breadcrumb-item>', () => {
let el: WaBreadcrumbItem;
describe('when not provided a href attribute', () => {
before(async () => {
el = await fixture<WaBreadcrumbItem>(html` <wa-breadcrumb-item>Home</wa-breadcrumb-item> `);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should hide the separator from screen readers', () => {
const separator = el.shadowRoot!.querySelector<HTMLSpanElement>('[part~="separator"]');
expect(separator).attribute('aria-hidden', 'true');
});
it('should render a HTMLButtonElement as the part "label", with a set type "button"', () => {
const button = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="label"]');
expect(button).to.exist;
expect(button).attribute('type', 'button');
});
});
describe('when provided a href attribute', () => {
describe('and no target', () => {
before(async () => {
el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/">Home</wa-breadcrumb-item>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should render a HTMLAnchorElement as the part "label", with the supplied href value', () => {
const hyperlink = el.shadowRoot!.querySelector<HTMLAnchorElement>('[part~="label"]');
expect(hyperlink).attribute('href', 'https://jsonplaceholder.typicode.com/');
});
});
describe('and target, without rel', () => {
before(async () => {
el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/" target="_blank">Help</wa-breadcrumb-item>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
describe('should render a HTMLAnchorElement as the part "label"', () => {
let hyperlink: HTMLAnchorElement | null;
before(() => {
hyperlink = el.shadowRoot!.querySelector<HTMLAnchorElement>('[part~="label"]');
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when not provided a href attribute', () => {
it('should hide the separator from screen readers', async () => {
const el = await fixture<WaBreadcrumbItem>(html` <wa-breadcrumb-item>Home</wa-breadcrumb-item> `);
const separator = el.shadowRoot!.querySelector<HTMLSpanElement>('[part~="separator"]');
expect(separator).attribute('aria-hidden', 'true');
});
it('should use the supplied href value, as the href attribute value', () => {
expect(hyperlink).attribute('href', 'https://jsonplaceholder.typicode.com/');
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumbItem>(html` <wa-breadcrumb-item>Home</wa-breadcrumb-item> `);
await expect(el).to.be.accessible(el);
});
it('should default rel attribute to "noreferrer noopener"', () => {
expect(hyperlink).attribute('rel', 'noreferrer noopener');
it('should hide the separator from screen readers', async () => {
const el = await fixture<WaBreadcrumbItem>(html` <wa-breadcrumb-item>Home</wa-breadcrumb-item> `);
const separator = el.shadowRoot!.querySelector<HTMLSpanElement>('[part~="separator"]');
expect(separator).attribute('aria-hidden', 'true');
});
it('should render a HTMLButtonElement as the part "label", with a set type "button"', async () => {
const el = await fixture<WaBreadcrumbItem>(html` <wa-breadcrumb-item>Home</wa-breadcrumb-item> `);
const button = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="label"]');
expect(button).to.exist;
expect(button).attribute('type', 'button');
});
});
describe('when provided a href attribute', () => {
describe('and no target', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/">Home</wa-breadcrumb-item>
`);
await expect(el).to.be.accessible();
});
it('should render a HTMLAnchorElement as the part "label", with the supplied href value', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/">Home</wa-breadcrumb-item>
`);
const hyperlink = el.shadowRoot!.querySelector<HTMLAnchorElement>('[part~="label"]');
expect(hyperlink).attribute('href', 'https://jsonplaceholder.typicode.com/');
});
});
describe('and target, without rel', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/" target="_blank">Help</wa-breadcrumb-item>
`);
await expect(el).to.be.accessible();
});
describe('should render a HTMLAnchorElement as the part "label"', () => {
it('should use the supplied href value, as the href attribute value', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/" target="_blank"
>Help</wa-breadcrumb-item
>
`);
const hyperlink: HTMLAnchorElement | null =
el.shadowRoot!.querySelector<HTMLAnchorElement>('[part~="label"]');
expect(hyperlink).attribute('href', 'https://jsonplaceholder.typicode.com/');
});
it('should default rel attribute to "noreferrer noopener"', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/" target="_blank"
>Help</wa-breadcrumb-item
>
`);
const hyperlink: HTMLAnchorElement | null =
el.shadowRoot!.querySelector<HTMLAnchorElement>('[part~="label"]');
expect(hyperlink).attribute('rel', 'noreferrer noopener');
});
});
});
describe('and target, with rel', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/" target="_blank" rel="alternate"
>Help</wa-breadcrumb-item
>
`);
await expect(el).to.be.accessible();
});
describe('should render a HTMLAnchorElement', () => {
it('should use the supplied href value, as the href attribute value', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/" target="_blank" rel="alternate"
>Help</wa-breadcrumb-item
>
`);
const hyperlink: HTMLAnchorElement | null = el.shadowRoot!.querySelector<HTMLAnchorElement>('a');
expect(hyperlink).attribute('href', 'https://jsonplaceholder.typicode.com/');
});
it('should use the supplied rel value, as the rel attribute value', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/" target="_blank" rel="alternate"
>Help</wa-breadcrumb-item
>
`);
const hyperlink: HTMLAnchorElement | null = el.shadowRoot!.querySelector<HTMLAnchorElement>('a');
expect(hyperlink).attribute('rel', 'alternate');
});
});
});
});
describe('when provided an element in the slot "prefix" to support prefix icons', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<span class="prefix-example" slot="prefix">/</span>
Home
</wa-breadcrumb-item>
`);
await expect(el).to.be.accessible();
});
it('should accept as an assigned child in the shadow root', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<span class="prefix-example" slot="prefix">/</span>
Home
</wa-breadcrumb-item>
`);
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=prefix]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
});
describe('when provided an element in the slot "suffix" to support suffix icons', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<span class="prefix-example" slot="suffix">/</span>
Security
</wa-breadcrumb-item>
`);
await expect(el).to.be.accessible();
// await aTimeout(1)
});
it('should accept as an assigned child in the shadow root', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<span class="prefix-example" slot="suffix">/</span>
Security
</wa-breadcrumb-item>
`);
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=suffix]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
});
describe('when rendering a wa-dropdown in the default slot', () => {
it('should not render a link or button tag, but a div wrapper', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<wa-dropdown>
<wa-button slot="trigger" size="small" circle>
<wa-icon label="More options" name="ellipsis"></wa-icon>
</wa-button>
<wa-menu>
<wa-menu-item type="checkbox" checked>Web Design</wa-menu-item>
<wa-menu-item type="checkbox">Web Development</wa-menu-item>
<wa-menu-item type="checkbox">Marketing</wa-menu-item>
</wa-menu>
</wa-dropdown>
</wa-breadcrumb-item>
`);
await expect(el).to.be.accessible();
expect(el.shadowRoot!.querySelector('a')).to.be.null;
expect(el.shadowRoot!.querySelector('button')).to.be.null;
expect(el.shadowRoot!.querySelector('.breadcrumb-item__label--dropdown')).not.to.be.null;
});
});
});
describe('and target, with rel', () => {
before(async () => {
el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item href="https://jsonplaceholder.typicode.com/" target="_blank" rel="alternate"
>Help</wa-breadcrumb-item
>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
describe('should render a HTMLAnchorElement', () => {
let hyperlink: HTMLAnchorElement | null;
before(() => {
hyperlink = el.shadowRoot!.querySelector<HTMLAnchorElement>('a');
});
it('should use the supplied href value, as the href attribute value', () => {
expect(hyperlink).attribute('href', 'https://jsonplaceholder.typicode.com/');
});
it('should use the supplied rel value, as the rel attribute value', () => {
expect(hyperlink).attribute('rel', 'alternate');
});
});
});
});
describe('when provided an element in the slot "prefix" to support prefix icons', () => {
before(async () => {
el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<span class="prefix-example" slot="prefix">/</span>
Home
</wa-breadcrumb-item>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should accept as an assigned child in the shadow root', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=prefix]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
});
describe('when provided an element in the slot "suffix" to support suffix icons', () => {
before(async () => {
el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<span class="prefix-example" slot="suffix">/</span>
Security
</wa-breadcrumb-item>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should accept as an assigned child in the shadow root', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=suffix]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
});
describe('when rendering a wa-dropdown in the default slot', () => {
it('should not render a link or button tag, but a div wrapper', async () => {
el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<wa-dropdown>
<wa-button slot="trigger" size="small" circle>
<wa-icon label="More options" name="ellipsis"></wa-icon>
</wa-button>
<wa-menu>
<wa-menu-item type="checkbox" checked>Web Design</wa-menu-item>
<wa-menu-item type="checkbox">Web Development</wa-menu-item>
<wa-menu-item type="checkbox">Marketing</wa-menu-item>
</wa-menu>
</wa-dropdown>
</wa-breadcrumb-item>
`);
await expect(el).to.be.accessible();
expect(el.shadowRoot!.querySelector('a')).to.be.null;
expect(el.shadowRoot!.querySelector('button')).to.be.null;
expect(el.shadowRoot!.querySelector('.breadcrumb-item__label--dropdown')).not.to.be.null;
});
});
}
});

View File

@@ -1,4 +1,6 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaBreadcrumb from './breadcrumb.js';
// The default link color just misses AA contrast, but the next step up is way too dark. Maybe we can solve this in the
@@ -6,101 +8,126 @@ import type WaBreadcrumb from './breadcrumb.js';
const ignoredRules = ['color-contrast'];
describe('<wa-breadcrumb>', () => {
let el: WaBreadcrumb;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when provided a standard list of el-breadcrumb-item children and no parameters', () => {
it('should render wa-icon as separator', async () => {
const el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<wa-breadcrumb-item>Catalog</wa-breadcrumb-item>
<wa-breadcrumb-item>Clothing</wa-breadcrumb-item>
<wa-breadcrumb-item>Women's</wa-breadcrumb-item>
<wa-breadcrumb-item>Shirts &amp; Tops</wa-breadcrumb-item>
</wa-breadcrumb>
`);
describe('when provided a standard list of el-breadcrumb-item children and no parameters', () => {
before(async () => {
el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<wa-breadcrumb-item>Catalog</wa-breadcrumb-item>
<wa-breadcrumb-item>Clothing</wa-breadcrumb-item>
<wa-breadcrumb-item>Women's</wa-breadcrumb-item>
<wa-breadcrumb-item>Shirts &amp; Tops</wa-breadcrumb-item>
</wa-breadcrumb>
`);
expect(el.querySelectorAll('wa-icon').length).to.eq(4);
});
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<wa-breadcrumb-item>Catalog</wa-breadcrumb-item>
<wa-breadcrumb-item>Clothing</wa-breadcrumb-item>
<wa-breadcrumb-item>Women's</wa-breadcrumb-item>
<wa-breadcrumb-item>Shirts &amp; Tops</wa-breadcrumb-item>
</wa-breadcrumb>
`);
await expect(el).to.be.accessible({ ignoredRules });
});
it('should attach aria-current "page" on the last breadcrumb item.', async () => {
const el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<wa-breadcrumb-item>Catalog</wa-breadcrumb-item>
<wa-breadcrumb-item>Clothing</wa-breadcrumb-item>
<wa-breadcrumb-item>Women's</wa-breadcrumb-item>
<wa-breadcrumb-item>Shirts &amp; Tops</wa-breadcrumb-item>
</wa-breadcrumb>
`);
const breadcrumbItems = el.querySelectorAll('wa-breadcrumb-item');
const lastNode = breadcrumbItems[3];
expect(lastNode).attribute('aria-current', 'page');
});
});
describe('when provided a standard list of el-breadcrumb-item children and an element in the slot "separator" to support Custom Separators', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<span class="replacement-separator" slot="separator">/</span>
<wa-breadcrumb-item>First</wa-breadcrumb-item>
<wa-breadcrumb-item>Second</wa-breadcrumb-item>
<wa-breadcrumb-item>Third</wa-breadcrumb-item>
</wa-breadcrumb>
`);
await expect(el).to.be.accessible({ ignoredRules });
});
it('should accept "separator" as an assigned child in the shadow root', async () => {
const el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<span class="replacement-separator" slot="separator">/</span>
<wa-breadcrumb-item>First</wa-breadcrumb-item>
<wa-breadcrumb-item>Second</wa-breadcrumb-item>
<wa-breadcrumb-item>Third</wa-breadcrumb-item>
</wa-breadcrumb>
`);
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=separator]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should replace the wa-icon separator with the provided separator', async () => {
const el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<span class="replacement-separator" slot="separator">/</span>
<wa-breadcrumb-item>First</wa-breadcrumb-item>
<wa-breadcrumb-item>Second</wa-breadcrumb-item>
<wa-breadcrumb-item>Third</wa-breadcrumb-item>
</wa-breadcrumb>
`);
expect(el.querySelectorAll('.replacement-separator').length).to.eq(4);
expect(el.querySelectorAll('wa-icon').length).to.eq(0);
});
});
describe('when provided a standard list of el-breadcrumb-item children and an element in the slot "prefix" to support prefix icons', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<wa-breadcrumb-item>
<span class="prefix-example" slot="prefix">/</span>
Home
</wa-breadcrumb-item>
<wa-breadcrumb-item>First</wa-breadcrumb-item>
<wa-breadcrumb-item>Second</wa-breadcrumb-item>
<wa-breadcrumb-item>Third</wa-breadcrumb-item>
</wa-breadcrumb>
`);
await expect(el).to.be.accessible({ ignoredRules });
});
});
describe('when provided a standard list of el-breadcrumb-item children and an element in the slot "suffix" to support suffix icons', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<wa-breadcrumb-item>First</wa-breadcrumb-item>
<wa-breadcrumb-item>Second</wa-breadcrumb-item>
<wa-breadcrumb-item>Third</wa-breadcrumb-item>
<wa-breadcrumb-item>
<span class="prefix-example" slot="suffix">/</span>
Security
</wa-breadcrumb-item>
</wa-breadcrumb>
`);
await expect(el).to.be.accessible({ ignoredRules });
});
});
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('should render wa-icon as separator', () => {
expect(el.querySelectorAll('wa-icon').length).to.eq(4);
});
it('should attach aria-current "page" on the last breadcrumb item.', () => {
const breadcrumbItems = el.querySelectorAll('wa-breadcrumb-item');
const lastNode = breadcrumbItems[3];
expect(lastNode).attribute('aria-current', 'page');
});
});
describe('when provided a standard list of el-breadcrumb-item children and an element in the slot "separator" to support Custom Separators', () => {
before(async () => {
el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<span class="replacement-separator" slot="separator">/</span>
<wa-breadcrumb-item>First</wa-breadcrumb-item>
<wa-breadcrumb-item>Second</wa-breadcrumb-item>
<wa-breadcrumb-item>Third</wa-breadcrumb-item>
</wa-breadcrumb>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
it('should accept "separator" as an assigned child in the shadow root', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=separator]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should replace the wa-icon separator with the provided separator', () => {
expect(el.querySelectorAll('.replacement-separator').length).to.eq(4);
expect(el.querySelectorAll('wa-icon').length).to.eq(0);
});
});
describe('when provided a standard list of el-breadcrumb-item children and an element in the slot "prefix" to support prefix icons', () => {
before(async () => {
el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<wa-breadcrumb-item>
<span class="prefix-example" slot="prefix">/</span>
Home
</wa-breadcrumb-item>
<wa-breadcrumb-item>First</wa-breadcrumb-item>
<wa-breadcrumb-item>Second</wa-breadcrumb-item>
<wa-breadcrumb-item>Third</wa-breadcrumb-item>
</wa-breadcrumb>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
});
describe('when provided a standard list of el-breadcrumb-item children and an element in the slot "suffix" to support suffix icons', () => {
before(async () => {
el = await fixture<WaBreadcrumb>(html`
<wa-breadcrumb>
<wa-breadcrumb-item>First</wa-breadcrumb-item>
<wa-breadcrumb-item>Second</wa-breadcrumb-item>
<wa-breadcrumb-item>Third</wa-breadcrumb-item>
<wa-breadcrumb-item>
<span class="prefix-example" slot="suffix">/</span>
Security
</wa-breadcrumb-item>
</wa-breadcrumb>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
});
}
});

View File

@@ -1,94 +1,99 @@
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import { elementUpdated, expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaButtonGroup from './button-group.js';
describe('<wa-button-group>', () => {
describe('defaults ', () => {
it('passes accessibility test', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
await expect(group).to.be.accessible();
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('defaults ', () => {
it('default label empty', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
expect(group.label).to.equal('');
});
it('passes accessibility test', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
await expect(group).to.be.accessible();
});
});
describe('slotted button classes', () => {
it('slotted buttons have the right classes applied based on their order', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
const allButtons = group.querySelectorAll('wa-button');
const hasGroupClass = Array.from(allButtons).every(button =>
button.classList.contains('wa-button-group__button')
);
expect(hasGroupClass).to.be.true;
expect(allButtons[0]).to.have.class('wa-button-group__button--first');
expect(allButtons[1]).to.have.class('wa-button-group__button--inner');
expect(allButtons[2]).to.have.class('wa-button-group__button--last');
});
});
describe('focus and blur events', () => {
it('toggles focus class to slotted buttons on focus/blur', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
const allButtons = group.querySelectorAll('wa-button');
allButtons[0].dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await elementUpdated(allButtons[0]);
expect(allButtons[0].classList.contains('wa-button-group__button--focus')).to.be.true;
allButtons[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
await elementUpdated(allButtons[0]);
expect(allButtons[0].classList.contains('wa-button-group__button--focus')).not.to.be.true;
});
});
describe('mouseover and mouseout events', () => {
it('toggles hover class to slotted buttons on mouseover/mouseout', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
const allButtons = group.querySelectorAll('wa-button');
allButtons[0].dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
await elementUpdated(allButtons[0]);
expect(allButtons[0].classList.contains('wa-button-group__button--hover')).to.be.true;
allButtons[0].dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
await elementUpdated(allButtons[0]);
expect(allButtons[0].classList.contains('wa-button-group__button--hover')).not.to.be.true;
});
});
});
it('default label empty', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
expect(group.label).to.equal('');
});
});
describe('slotted button classes', () => {
it('slotted buttons have the right classes applied based on their order', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
const allButtons = group.querySelectorAll('wa-button');
const hasGroupClass = Array.from(allButtons).every(button =>
button.classList.contains('wa-button-group__button')
);
expect(hasGroupClass).to.be.true;
expect(allButtons[0]).to.have.class('wa-button-group__button--first');
expect(allButtons[1]).to.have.class('wa-button-group__button--inner');
expect(allButtons[2]).to.have.class('wa-button-group__button--last');
});
});
describe('focus and blur events', () => {
it('toggles focus class to slotted buttons on focus/blur', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
const allButtons = group.querySelectorAll('wa-button');
allButtons[0].dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await elementUpdated(allButtons[0]);
expect(allButtons[0].classList.contains('wa-button-group__button--focus')).to.be.true;
allButtons[0].dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
await elementUpdated(allButtons[0]);
expect(allButtons[0].classList.contains('wa-button-group__button--focus')).not.to.be.true;
});
});
describe('mouseover and mouseout events', () => {
it('toggles hover class to slotted buttons on mouseover/mouseout', async () => {
const group = await fixture<WaButtonGroup>(html`
<wa-button-group>
<wa-button>Button 1 Label</wa-button>
<wa-button>Button 2 Label</wa-button>
<wa-button>Button 3 Label</wa-button>
</wa-button-group>
`);
const allButtons = group.querySelectorAll('wa-button');
allButtons[0].dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
await elementUpdated(allButtons[0]);
expect(allButtons[0].classList.contains('wa-button-group__button--hover')).to.be.true;
allButtons[0].dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
await elementUpdated(allButtons[0]);
expect(allButtons[0].classList.contains('wa-button-group__button--hover')).not.to.be.true;
});
});
}
});

View File

@@ -1,306 +1,314 @@
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import sinon from 'sinon';
import type WaButton from './button.js';
const variants = ['brand', 'success', 'neutral', 'warning', 'danger'];
describe('<wa-button>', async () => {
describe('accessibility tests', () => {
variants.forEach(variant => {
it(`should be accessible when variant is "${variant}"`, async () => {
const el = await fixture<WaButton>(html` <wa-button variant="${variant}"> Button Label </wa-button> `);
await expect(el).to.be.accessible();
});
});
describe('<wa-button>', () => {
it('form control base tests', async () => {
await Promise.allSettled([
runFormControlBaseTests({
tagName: 'wa-button',
variantName: 'type="button"',
init: (control: WaButton) => {
control.type = 'button';
}
}),
runFormControlBaseTests({
tagName: 'wa-button',
variantName: 'type="submit"',
init: (control: WaButton) => {
control.type = 'submit';
}
}),
runFormControlBaseTests({
tagName: 'wa-button',
variantName: 'href="xyz"',
init: (control: WaButton) => {
control.href = 'some-url';
}
})
]);
});
describe('when provided no parameters', () => {
it('passes accessibility test', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
await expect(el).to.be.accessible();
});
it('default values are set correctly', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
expect(el.title).to.equal('');
expect(el.variant).to.equal('neutral');
expect(el.appearance).to.equal('filled');
expect(el.size).to.equal('medium');
expect(el.disabled).to.equal(false);
expect(el.caret).to.equal(false);
expect(el.loading).to.equal(false);
expect(el.pill).to.equal(false);
});
it('should render as a <button>', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('button')).to.exist;
expect(el.shadowRoot!.querySelector('a')).not.to.exist;
});
it('should not have a spinner present', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('wa-spinner')).not.to.exist;
});
it('should not have a caret present', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
expect(el.shadowRoot?.querySelector('[part~="caret"]')).not.to.exist;
});
});
describe('when disabled', () => {
it('passes accessibility test', async () => {
const el = await fixture<WaButton>(html` <wa-button disabled>Button Label</wa-button> `);
await expect(el).to.be.accessible();
});
it('should disable the native <button> when rendering a <button>', async () => {
const el = await fixture<WaButton>(html` <wa-button disabled>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('button[disabled]')).to.exist;
});
it('should not disable the native <a> when rendering an <a>', async () => {
const el = await fixture<WaButton>(html` <wa-button href="some/path" disabled>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('a[disabled]')).not.to.exist;
});
});
it('should have title if title attribute is set', async () => {
const el = await fixture<WaButton>(html` <wa-button title="Test"></wa-button> `);
const button = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="base"]')!;
expect(button.title).to.equal('Test');
});
describe('when loading', () => {
it('should have a spinner present', async () => {
const el = await fixture<WaButton>(html` <wa-button loading>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('wa-spinner')).to.exist;
});
});
describe('when caret', () => {
it('should have a caret present', async () => {
const el = await fixture<WaButton>(html` <wa-button caret>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('[part~="caret"]')).to.exist;
});
});
describe('when href is present', () => {
it('should render as an <a>', async () => {
const el = await fixture<WaButton>(html` <wa-button href="some/path">Button Label</wa-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<WaButton>(html`
<wa-button href="https://example.com/" target="_blank">Link</wa-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<WaButton>(html`
<wa-button href="https://example.com/" target="_blank" rel="">Link</wa-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<WaButton>(html`
<wa-button href="https://example.com/" target="_blank" rel="1">Link</wa-button>
`);
const link = el.shadowRoot!.querySelector('a')!;
expect(link?.getAttribute('rel')).to.equal('1');
});
});
describe('when submitting a form', () => {
it('should submit when the button is inside the form', async () => {
const form = await fixture<HTMLFormElement>(html`
<form action="" method="post">
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector<WaButton>('wa-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => event.preventDefault());
form.addEventListener('submit', handleSubmit);
button.click();
expect(handleSubmit).to.have.been.calledOnce;
});
it('should submit when the button is outside the form and has a form attribute', async () => {
const el = await fixture(html`
<div>
<form id="a" action="" method="post"></form>
<wa-button type="submit" form="a">Submit</wa-button>
</div>
`);
const form = el.querySelector<HTMLFormElement>('form')!;
const button = el.querySelector<WaButton>('wa-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => event.preventDefault());
form.addEventListener('submit', handleSubmit);
button.click();
expect(handleSubmit).to.have.been.calledOnce;
});
it('should override form attributes when formaction, formmethod, formnovalidate, and formtarget are used inside a form', async () => {
const form = await fixture(html`
<form id="a" action="foo" method="post" target="_self">
<wa-button type="submit" form="a" formaction="bar" formmethod="get" formtarget="_blank" formnovalidate>
Submit
</wa-button>
</form>
`);
const button = form.querySelector<WaButton>('wa-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => {
submitter = event.submitter as HTMLButtonElement;
event.preventDefault();
});
let submitter!: HTMLButtonElement;
form.addEventListener('submit', handleSubmit);
button.click();
expect(handleSubmit).to.have.been.calledOnce;
expect(submitter.formAction.endsWith('/bar')).to.be.true;
expect(submitter.formMethod).to.equal('get');
expect(submitter.formTarget).to.equal('_blank');
expect(submitter.formNoValidate).to.be.true;
});
it('should override form attributes when formaction, formmethod, formnovalidate, and formtarget are used outside a form', async () => {
const el = await fixture(html`
<div>
<form id="a" action="foo" method="post" target="_self"></form>
<wa-button type="submit" form="a" formaction="bar" formmethod="get" formtarget="_blank" formnovalidate>
Submit
</wa-button>
</div>
`);
const form = el.querySelector<HTMLFormElement>('form')!;
const button = el.querySelector<WaButton>('wa-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => {
submitter = event.submitter as HTMLButtonElement;
event.preventDefault();
});
let submitter!: HTMLButtonElement;
form.addEventListener('submit', handleSubmit);
button.click();
expect(handleSubmit).to.have.been.calledOnce;
expect(submitter.formAction.endsWith('/bar')).to.be.true;
expect(submitter.formMethod).to.equal('get');
expect(submitter.formTarget).to.equal('_blank');
expect(submitter.formNoValidate).to.be.true;
});
it('should only submit button name / value pair when the form is submitted', async () => {
const form = await fixture<HTMLFormElement>(
html`<form>
<wa-button type="submit" name="btn-1" value="value-1">Button 1</wa-button>
<wa-button type="submit" name="btn-2" value="value-2">Button 2</wa-button>
</form>`
);
let formData = new FormData(form);
let submitter: null | HTMLButtonElement = document.createElement('button');
form.addEventListener('submit', e => {
e.preventDefault();
formData = new FormData(form);
submitter = e.submitter as HTMLButtonElement;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('accessibility tests', () => {
variants.forEach(variant => {
it(`should be accessible when variant is "${variant}"`, async () => {
const el = await fixture<WaButton>(html` <wa-button variant="${variant}"> Button Label </wa-button> `);
await expect(el).to.be.accessible();
});
});
});
expect(formData.get('btn-1')).to.be.null;
expect(formData.get('btn-2')).to.be.null;
describe('when provided no parameters', () => {
it('passes accessibility test', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
await expect(el).to.be.accessible();
});
form.querySelector('wa-button')?.click();
await aTimeout(0);
it('default values are set correctly', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
expect(formData.get('btn-1')).to.be.null;
expect(formData.get('btn-2')).to.be.null;
expect(el.title).to.equal('');
expect(el.variant).to.equal('neutral');
expect(el.appearance).to.equal('filled');
expect(el.size).to.equal('medium');
expect(el.disabled).to.equal(false);
expect(el.caret).to.equal(false);
expect(el.loading).to.equal(false);
expect(el.pill).to.equal(false);
});
expect(submitter.name).to.equal('btn-1');
expect(submitter.value).to.equal('value-1');
it('should render as a <button>', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('button')).to.exist;
expect(el.shadowRoot!.querySelector('a')).not.to.exist;
});
form.querySelectorAll('wa-button')[1]?.click();
await aTimeout(0);
it('should not have a spinner present', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('wa-spinner')).not.to.exist;
});
expect(formData.get('btn-1')).to.be.null;
expect(formData.get('btn-2')).to.be.null;
it('should not have a caret present', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button Label</wa-button> `);
expect(el.shadowRoot?.querySelector('[part~="caret"]')).not.to.exist;
});
});
expect(submitter.name).to.equal('btn-2');
expect(submitter.value).to.equal('value-2');
describe('when disabled', () => {
it('passes accessibility test', async () => {
const el = await fixture<WaButton>(html` <wa-button disabled>Button Label</wa-button> `);
await expect(el).to.be.accessible();
});
it('should disable the native <button> when rendering a <button>', async () => {
const el = await fixture<WaButton>(html` <wa-button disabled>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('button[disabled]')).to.exist;
});
it('should not disable the native <a> when rendering an <a>', async () => {
const el = await fixture<WaButton>(html` <wa-button href="some/path" disabled>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('a[disabled]')).not.to.exist;
});
});
it('should have title if title attribute is set', async () => {
const el = await fixture<WaButton>(html` <wa-button title="Test"></wa-button> `);
const button = el.shadowRoot!.querySelector<HTMLButtonElement>('[part~="base"]')!;
expect(button.title).to.equal('Test');
});
describe('when loading', () => {
it('should have a spinner present', async () => {
const el = await fixture<WaButton>(html` <wa-button loading>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('wa-spinner')).to.exist;
});
});
describe('when caret', () => {
it('should have a caret present', async () => {
const el = await fixture<WaButton>(html` <wa-button caret>Button Label</wa-button> `);
expect(el.shadowRoot!.querySelector('[part~="caret"]')).to.exist;
});
});
describe('when href is present', () => {
it('should render as an <a>', async () => {
const el = await fixture<WaButton>(html` <wa-button href="some/path">Button Label</wa-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<WaButton>(html`
<wa-button href="https://example.com/" target="_blank">Link</wa-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<WaButton>(html`
<wa-button href="https://example.com/" target="_blank" rel="">Link</wa-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<WaButton>(html`
<wa-button href="https://example.com/" target="_blank" rel="1">Link</wa-button>
`);
const link = el.shadowRoot!.querySelector('a')!;
expect(link?.getAttribute('rel')).to.equal('1');
});
});
describe('when submitting a form', () => {
it('should submit when the button is inside the form', async () => {
const form = await fixture<HTMLFormElement>(html`
<form action="" method="post">
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector<WaButton>('wa-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => event.preventDefault());
form.addEventListener('submit', handleSubmit);
button.click();
expect(handleSubmit).to.have.been.calledOnce;
});
it('should submit when the button is outside the form and has a form attribute', async () => {
const el = await fixture(html`
<div>
<form id="a" action="" method="post"></form>
<wa-button type="submit" form="a">Submit</wa-button>
</div>
`);
const form = el.querySelector<HTMLFormElement>('form')!;
const button = el.querySelector<WaButton>('wa-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => event.preventDefault());
form.addEventListener('submit', handleSubmit);
button.click();
expect(handleSubmit).to.have.been.calledOnce;
});
it('should override form attributes when formaction, formmethod, formnovalidate, and formtarget are used inside a form', async () => {
const form = await fixture(html`
<form id="a" action="foo" method="post" target="_self">
<wa-button type="submit" form="a" formaction="bar" formmethod="get" formtarget="_blank" formnovalidate>
Submit
</wa-button>
</form>
`);
const button = form.querySelector<WaButton>('wa-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => {
submitter = event.submitter as HTMLButtonElement;
event.preventDefault();
});
let submitter!: HTMLButtonElement;
form.addEventListener('submit', handleSubmit);
button.click();
expect(handleSubmit).to.have.been.calledOnce;
expect(submitter.formAction.endsWith('/bar')).to.be.true;
expect(submitter.formMethod).to.equal('get');
expect(submitter.formTarget).to.equal('_blank');
expect(submitter.formNoValidate).to.be.true;
});
it('should override form attributes when formaction, formmethod, formnovalidate, and formtarget are used outside a form', async () => {
const el = await fixture(html`
<div>
<form id="a" action="foo" method="post" target="_self"></form>
<wa-button type="submit" form="a" formaction="bar" formmethod="get" formtarget="_blank" formnovalidate>
Submit
</wa-button>
</div>
`);
const form = el.querySelector<HTMLFormElement>('form')!;
const button = el.querySelector<WaButton>('wa-button')!;
const handleSubmit = sinon.spy((event: SubmitEvent) => {
submitter = event.submitter as HTMLButtonElement;
event.preventDefault();
});
let submitter!: HTMLButtonElement;
form.addEventListener('submit', handleSubmit);
button.click();
expect(handleSubmit).to.have.been.calledOnce;
expect(submitter.formAction.endsWith('/bar')).to.be.true;
expect(submitter.formMethod).to.equal('get');
expect(submitter.formTarget).to.equal('_blank');
expect(submitter.formNoValidate).to.be.true;
});
it('should only submit button name / value pair when the form is submitted', async () => {
const form = await fixture<HTMLFormElement>(
html`<form>
<wa-button type="submit" name="btn-1" value="value-1">Button 1</wa-button>
<wa-button type="submit" name="btn-2" value="value-2">Button 2</wa-button>
</form>`
);
let formData = new FormData(form);
let submitter: null | HTMLButtonElement = document.createElement('button');
form.addEventListener('submit', e => {
e.preventDefault();
formData = new FormData(form);
submitter = e.submitter as HTMLButtonElement;
});
expect(formData.get('btn-1')).to.be.null;
expect(formData.get('btn-2')).to.be.null;
form.querySelector('wa-button')?.click();
await aTimeout(0);
expect(formData.get('btn-1')).to.be.null;
expect(formData.get('btn-2')).to.be.null;
expect(submitter.name).to.equal('btn-1');
expect(submitter.value).to.equal('value-1');
form.querySelectorAll('wa-button')[1]?.click();
await aTimeout(0);
expect(formData.get('btn-1')).to.be.null;
expect(formData.get('btn-2')).to.be.null;
expect(submitter.name).to.equal('btn-2');
expect(submitter.value).to.equal('value-2');
});
});
describe('when using methods', () => {
it('should emit wa-focus and wa-blur when the button is focused and blurred', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button</wa-button> `);
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
el.addEventListener('wa-focus', focusHandler);
el.addEventListener('wa-blur', blurHandler);
el.focus();
await waitUntil(() => focusHandler.calledOnce);
el.blur();
await waitUntil(() => blurHandler.calledOnce);
expect(focusHandler).to.have.been.calledOnce;
expect(blurHandler).to.have.been.calledOnce;
});
it('should emit a click event when calling click()', async () => {
const el = await fixture<WaButton>(html` <wa-button></wa-button> `);
const clickHandler = sinon.spy();
el.addEventListener('click', clickHandler);
el.click();
await waitUntil(() => clickHandler.calledOnce);
expect(clickHandler).to.have.been.calledOnce;
});
});
});
});
describe('when using methods', () => {
it('should emit wa-focus and wa-blur when the button is focused and blurred', async () => {
const el = await fixture<WaButton>(html` <wa-button>Button</wa-button> `);
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
el.addEventListener('wa-focus', focusHandler);
el.addEventListener('wa-blur', blurHandler);
el.focus();
await waitUntil(() => focusHandler.calledOnce);
el.blur();
await waitUntil(() => blurHandler.calledOnce);
expect(focusHandler).to.have.been.calledOnce;
expect(blurHandler).to.have.been.calledOnce;
});
it('should emit a click event when calling click()', async () => {
const el = await fixture<WaButton>(html` <wa-button></wa-button> `);
const clickHandler = sinon.spy();
el.addEventListener('click', clickHandler);
el.click();
await waitUntil(() => clickHandler.calledOnce);
expect(clickHandler).to.have.been.calledOnce;
});
});
await Promise.all([
runFormControlBaseTests({
tagName: 'wa-button',
variantName: 'type="button"',
init: (control: WaButton) => {
control.type = 'button';
}
}),
runFormControlBaseTests({
tagName: 'wa-button',
variantName: 'type="submit"',
init: (control: WaButton) => {
control.type = 'submit';
}
}),
runFormControlBaseTests({
tagName: 'wa-button',
variantName: 'href="xyz"',
init: (control: WaButton) => {
control.href = 'some-url';
}
})
]);
}
});

View File

@@ -108,7 +108,7 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
* The value of the button, submitted as a pair with the button's name as part of the form data, but only when this
* button is the submitter. This attribute is ignored when `href` is present.
*/
@property({ reflect: true }) value = '';
@property({ reflect: true }) value: string | null = null;
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href = '';
@@ -184,7 +184,7 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
if (this.name) {
button.name = this.name;
}
button.value = this.value;
button.value = this.value || '';
['form', 'formaction', 'formenctype', 'formmethod', 'formnovalidate', 'formtarget'].forEach(attr => {
if (this.hasAttribute(attr)) {

View File

@@ -1,14 +1,32 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaCallout from './callout.js';
it('Should properly render callout variants', async () => {
const variants = ['brand', 'success', 'neutral', 'warning', 'danger'];
describe('<wa-callout>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('Should properly render callout variants', async () => {
const variants = ['brand', 'success', 'neutral', 'warning', 'danger'];
for (const variant of variants) {
const callout = await fixture<WaCallout>(html`<wa-callout variant="${variant}">I am a callout</wa-callout>`);
const base = callout.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
for (const variant of variants) {
const callout = await fixture<WaCallout>(html`<wa-callout variant="${variant}">I am a callout</wa-callout>`);
expect(base).to.have.class(`callout--${variant}`);
await expect(callout).to.be.accessible();
await customElements.whenDefined('wa-callout');
await callout.updateComplete;
const base = callout.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
expect(base).to.have.class(`callout--${variant}`);
// @TODO: For some reason this fails only in CI. I have no clue why. I tested this scenario on the real site, and it works as expected. [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
await expect(callout).to.be.accessible();
}
});
});
}
});

View File

@@ -1,137 +1,223 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaCard from './card.js';
describe('<wa-card>', () => {
let el: WaCard;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when provided no parameters', () => {
it('should render the child content provided.', async () => {
const el = await fixture<WaCard>(html`
<wa-card>This is just a basic card. No image, no header, and no footer. Just your content.</wa-card>
`);
expect(el.innerText).to.eq(
'This is just a basic card. No image, no header, and no footer. Just your content.'
);
});
describe('when provided no parameters', () => {
before(async () => {
el = await fixture<WaCard>(html`
<wa-card>This is just a basic card. No image, no header, and no footer. Just your content.</wa-card>
`);
it('should pass accessibility tests', async () => {
const el = await fixture<WaCard>(html`
<wa-card>This is just a basic card. No image, no header, and no footer. Just your content.</wa-card>
`);
await expect(el).to.be.accessible();
});
it('should contain the class card.', async () => {
const el = await fixture<WaCard>(html`
<wa-card>This is just a basic card. No image, no header, and no footer. Just your content.</wa-card>
`);
const card = el.shadowRoot!.querySelector('.card')!;
expect(card.classList.value.trim()).to.eq('card');
});
});
describe('when provided an element in the slot "header" to render a header', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-header>
<div slot="header">Header Title</div>
This card has a header. You can put all sorts of things in it!
</wa-card>`
);
await expect(el).to.be.accessible();
});
it('should render the child content provided.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-header>
<div slot="header">Header Title</div>
This card has a header. You can put all sorts of things in it!
</wa-card>`
);
expect(el.innerText).to.contain('This card has a header. You can put all sorts of things in it!');
});
it('render the header content provided.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-header>
<div slot="header">Header Title</div>
This card has a header. You can put all sorts of things in it!
</wa-card>`
);
const header = el.querySelector<HTMLElement>('div[slot=header]')!;
expect(header.innerText).eq('Header Title');
});
it('accept "header" as an assigned child in the shadow root.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-header>
<div slot="header">Header Title</div>
This card has a header. You can put all sorts of things in it!
</wa-card>`
);
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=header]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should contain the class card--has-header.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-header>
<div slot="header">Header Title</div>
This card has a header. You can put all sorts of things in it!
</wa-card>`
);
const card = el.shadowRoot!.querySelector('.card')!;
expect(card.classList.value.trim()).to.eq('card card--has-header');
});
});
describe('when provided an element in the slot "footer" to render a footer', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-footer>
This card has a footer. You can put all sorts of things in it!
<div slot="footer">Footer Content</div>
</wa-card>`
);
await expect(el).to.be.accessible();
});
it('should render the child content provided.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-footer>
This card has a footer. You can put all sorts of things in it!
<div slot="footer">Footer Content</div>
</wa-card>`
);
expect(el.innerText).to.contain('This card has a footer. You can put all sorts of things in it!');
});
it('render the footer content provided.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-footer>
This card has a footer. You can put all sorts of things in it!
<div slot="footer">Footer Content</div>
</wa-card>`
);
const footer = el.querySelector<HTMLElement>('div[slot=footer]')!;
expect(footer.innerText).eq('Footer Content');
});
it('accept "footer" as an assigned child in the shadow root.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-footer>
This card has a footer. You can put all sorts of things in it!
<div slot="footer">Footer Content</div>
</wa-card>`
);
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=footer]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should contain the class card--has-footer.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-footer>
This card has a footer. You can put all sorts of things in it!
<div slot="footer">Footer Content</div>
</wa-card>`
);
const card = el.shadowRoot!.querySelector('.card')!;
expect(card.classList.value.trim()).to.eq('card card--has-footer');
});
});
describe('when provided an element in the slot "image" to render a image', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-image>
<img
slot="image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt="A kitten walks towards camera on top of pallet."
/>
This is a kitten, but not just any kitten. This kitten likes walking along pallets.
</wa-card>`
);
await expect(el).to.be.accessible();
});
it('should render the child content provided.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-image>
<img
slot="image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt="A kitten walks towards camera on top of pallet."
/>
This is a kitten, but not just any kitten. This kitten likes walking along pallets.
</wa-card>`
);
expect(el.innerText).to.contain(
'This is a kitten, but not just any kitten. This kitten likes walking along pallets.'
);
});
it('accept "image" as an assigned child in the shadow root.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-image>
<img
slot="image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt="A kitten walks towards camera on top of pallet."
/>
This is a kitten, but not just any kitten. This kitten likes walking along pallets.
</wa-card>`
);
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=image]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should contain the class card--has-image.', async () => {
const el = await fixture<WaCard>(
html`<wa-card with-image>
<img
slot="image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt="A kitten walks towards camera on top of pallet."
/>
This is a kitten, but not just any kitten. This kitten likes walking along pallets.
</wa-card>`
);
const card = el.shadowRoot!.querySelector('.card')!;
expect(card.classList.value.trim()).to.eq('card card--has-image');
});
});
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should render the child content provided.', () => {
expect(el.innerText).to.eq('This is just a basic card. No image, no header, and no footer. Just your content.');
});
it('should contain the class card.', () => {
const card = el.shadowRoot!.querySelector('.card')!;
expect(card.classList.value.trim()).to.eq('card');
});
});
describe('when provided an element in the slot "header" to render a header', () => {
before(async () => {
el = await fixture<WaCard>(
html`<wa-card with-header>
<div slot="header">Header Title</div>
This card has a header. You can put all sorts of things in it!
</wa-card>`
);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should render the child content provided.', () => {
expect(el.innerText).to.contain('This card has a header. You can put all sorts of things in it!');
});
it('render the header content provided.', () => {
const header = el.querySelector<HTMLElement>('div[slot=header]')!;
expect(header.innerText).eq('Header Title');
});
it('accept "header" as an assigned child in the shadow root.', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=header]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should contain the class card--has-header.', () => {
const card = el.shadowRoot!.querySelector('.card')!;
expect(card.classList.value.trim()).to.eq('card card--has-header');
});
});
describe('when provided an element in the slot "footer" to render a footer', () => {
before(async () => {
el = await fixture<WaCard>(
html`<wa-card with-footer>
This card has a footer. You can put all sorts of things in it!
<div slot="footer">Footer Content</div>
</wa-card>`
);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should render the child content provided.', () => {
expect(el.innerText).to.contain('This card has a footer. You can put all sorts of things in it!');
});
it('render the footer content provided.', () => {
const footer = el.querySelector<HTMLElement>('div[slot=footer]')!;
expect(footer.innerText).eq('Footer Content');
});
it('accept "footer" as an assigned child in the shadow root.', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=footer]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should contain the class card--has-footer.', () => {
const card = el.shadowRoot!.querySelector('.card')!;
expect(card.classList.value.trim()).to.eq('card card--has-footer');
});
});
describe('when provided an element in the slot "image" to render a image', () => {
before(async () => {
el = await fixture<WaCard>(
html`<wa-card with-image>
<img
slot="image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
alt="A kitten walks towards camera on top of pallet."
/>
This is a kitten, but not just any kitten. This kitten likes walking along pallets.
</wa-card>`
);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should render the child content provided.', () => {
expect(el.innerText).to.contain(
'This is a kitten, but not just any kitten. This kitten likes walking along pallets.'
);
});
it('accept "image" as an assigned child in the shadow root.', () => {
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=image]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should contain the class card--has-image.', () => {
const card = el.shadowRoot!.querySelector('.card')!;
expect(card.classList.value.trim()).to.eq('card card--has-image');
});
});
}
});

View File

@@ -1,17 +1,23 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
describe('<wa-carousel-item>', () => {
it('should render a component', async () => {
const el = await fixture(html`<wa-carousel-item></wa-carousel-item> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render a component', async () => {
const el = await fixture(html`<wa-carousel-item></wa-carousel-item> `);
expect(el).to.exist;
});
expect(el).to.exist;
});
it('should pass accessibility tests', async () => {
// Arrange
const el = await fixture(html`<wa-carousel-item></wa-carousel-item>`);
it('should pass accessibility tests', async () => {
// Arrange
const el = await fixture(html`<wa-carousel-item></wa-carousel-item>`);
// Assert
await expect(el).to.be.accessible();
});
// Assert
await expect(el).to.be.accessible();
});
});
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ import { AutoplayController } from './autoplay-controller.js';
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { map } from 'lit/directives/map.js';
import { prefersReducedMotion } from '../../internal/animate.js';
@@ -58,6 +58,9 @@ export default class WaCarousel extends WebAwesomeElement {
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
@property({ type: Boolean, reflect: true }) loop = false;
@property({ type: Number, reflect: true }) slides = 0;
@property({ type: Number, reflect: true }) currentSlide = 0;
/** When set, show the carousel's navigation. */
@property({ type: Boolean, reflect: true }) navigation = false;
@@ -487,11 +490,22 @@ export default class WaCarousel extends WebAwesomeElement {
render() {
const { slidesPerMove, scrolling } = this;
const pagesCount = this.getPageCount();
const currentPage = this.getCurrentPage();
const prevEnabled = this.canScrollPrev();
const nextEnabled = this.canScrollNext();
const isLtr = this.matches(':dir(ltr)');
let pagesCount = 0;
let currentPage = 0;
let prevEnabled = false;
let nextEnabled = false;
// @TODO: This is a super hacky way to get rid of hydration mismatch errors. The ideal solution is users being able to pass in `pagesCount` and `currentPage` and then on firstUpdated to we update the value for them.
if (this.hasUpdated) {
pagesCount = this.getPageCount();
currentPage = this.getCurrentPage();
prevEnabled = this.canScrollPrev();
nextEnabled = this.canScrollNext();
}
// We can't rely on `this.matches()` on the server.
const isRTL = isServer ? this.dir === 'rtl' : this.matches(':dir(rtl)');
return html`
<div part="base" class="carousel">
@@ -513,7 +527,7 @@ export default class WaCarousel extends WebAwesomeElement {
@scroll="${this.handleScroll}"
@scrollend=${this.handleScrollEnd}
>
<slot></slot>
<slot @slotchange=${() => this.requestUpdate()}></slot>
</div>
${this.navigation
@@ -532,7 +546,7 @@ export default class WaCarousel extends WebAwesomeElement {
@click=${prevEnabled ? () => this.previous() : null}
>
<slot name="previous-icon">
<wa-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></wa-icon>
<wa-icon library="system" name="${isRTL ? 'chevron-right' : 'chevron-left'}"></wa-icon>
</slot>
</button>
@@ -549,7 +563,7 @@ export default class WaCarousel extends WebAwesomeElement {
@click=${nextEnabled ? () => this.next() : null}
>
<slot name="next-icon">
<wa-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></wa-icon>
<wa-icon library="system" name="${isRTL ? 'chevron-left' : 'chevron-right'}"></wa-icon>
</slot>
</button>
</div>
@@ -578,7 +592,7 @@ export default class WaCarousel extends WebAwesomeElement {
})}
</div>
`
: ''}
: html``}
</div>
`;
}

View File

@@ -1,373 +1,406 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type WaCheckbox from './checkbox.js';
describe('<wa-checkbox>', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox>Checkbox</wa-checkbox> `);
await expect(el).to.be.accessible();
});
runFormControlBaseTests('wa-checkbox');
it('default properties', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
expect(el.name).to.equal('');
expect(el.value).to.be.null;
expect(el.title).to.equal('');
expect(el.disabled).to.be.false;
expect(el.required).to.be.false;
expect(el.checked).to.be.false;
expect(el.indeterminate).to.be.false;
expect(el.defaultChecked).to.be.false;
expect(el.helpText).to.equal('');
});
it('should have title if title attribute is set', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox title="Test"></wa-checkbox> `);
const input = el.shadowRoot!.querySelector('input')!;
expect(input.title).to.equal('Test');
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox disabled></wa-checkbox> `);
const checkbox = el.shadowRoot!.querySelector('input')!;
expect(checkbox.disabled).to.be.true;
});
it('should be disabled when disabled property is set', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox></wa-checkbox>`);
const checkbox = el.shadowRoot!.querySelector('input')!;
el.disabled = true;
await el.updateComplete;
expect(checkbox.disabled).to.be.true;
});
it('should be valid by default', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
expect(el.checkValidity()).to.be.true;
});
it('should emit wa-change and wa-input when clicked', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.click();
await aTimeout(0);
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should emit wa-change and wa-input when toggled with spacebar', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await el.updateComplete;
await sendKeys({ press: ' ' });
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should not emit wa-change or wa-input when checked programmatically', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
el.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
el.checked = true;
await el.updateComplete;
await aTimeout(0);
el.checked = false;
await el.updateComplete;
await aTimeout(0);
});
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<WaCheckbox>(html` <wa-checkbox></wa-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`
<form>
<wa-checkbox name="a" value="1" checked></wa-checkbox>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox>Checkbox</wa-checkbox> `);
await expect(el).to.be.accessible();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
it('default properties', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('1');
});
it('should submit "on" when no value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-checkbox name="a" checked></wa-checkbox>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
expect(el.name).to.equal('');
expect(el.value).to.equal('on');
expect(el.title).to.equal('');
expect(el.disabled).to.be.false;
expect(el.required).to.be.false;
expect(el.checked).to.be.false;
expect(el.indeterminate).to.be.false;
expect(el.defaultChecked).to.be.false;
expect(el.helpText).to.equal('');
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
it('should have title if title attribute is set', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox title="Test"></wa-checkbox> `);
const input = el.shadowRoot!.querySelector('input')!;
await waitUntil(() => submitHandler.calledOnce);
expect(input.title).to.equal('Test');
});
expect(formData!.get('a')).to.equal('on');
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox disabled></wa-checkbox> `);
const checkbox = el.shadowRoot!.querySelector('input')!;
expect(checkbox.disabled).to.be.true;
});
it('should be disabled when disabled property is set', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox></wa-checkbox>`);
const checkbox = el.shadowRoot!.querySelector('input')!;
el.disabled = true;
await el.updateComplete;
expect(checkbox.disabled).to.be.true;
});
it('should be valid by default', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
expect(el.checkValidity()).to.be.true;
});
it('should emit wa-change and wa-input when clicked', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.click();
await aTimeout(0);
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should emit wa-change and wa-input when toggled with spacebar', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await el.updateComplete;
await sendKeys({ press: ' ' });
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should not emit wa-change or wa-input when checked programmatically', async () => {
const el = await fixture<WaCheckbox>(html` <wa-checkbox></wa-checkbox> `);
el.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
el.checked = true;
await el.updateComplete;
await aTimeout(0);
el.checked = false;
await el.updateComplete;
await aTimeout(0);
});
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<WaCheckbox>(html` <wa-checkbox></wa-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');
});
it('Should keep its form value when going from checked -> unchecked -> checked', async () => {
const form = await fixture<HTMLFormElement>(
html`<form><wa-checkbox name="test" value="myvalue" checked>Checked</wa-checkbox></form>`
);
const checkbox = form.querySelector('wa-checkbox')!;
expect(checkbox.checked).to.equal(true);
expect(checkbox.value).to.equal('myvalue');
expect(new FormData(form).get('test')).to.equal('myvalue');
checkbox.checked = false;
await checkbox.updateComplete;
expect(checkbox.checked).to.equal(false);
expect(checkbox.value).to.equal('myvalue');
expect(new FormData(form).get('test')).to.equal(null);
checkbox.checked = true;
await checkbox.updateComplete;
expect(checkbox.checked).to.equal(true);
expect(checkbox.value).to.equal('myvalue');
expect(new FormData(form).get('test')).to.equal('myvalue');
});
describe('when submitting a form', () => {
it('should submit the correct value when a value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-checkbox name="a" value="1" checked></wa-checkbox>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('1');
});
it('should submit "on" when no value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-checkbox name="a" checked></wa-checkbox>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('on');
});
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <wa-checkbox></wa-checkbox> `);
// Submitting the form after setting custom validity should not trigger the handler
checkbox.setCustomValidity('Invalid selection');
await checkbox.updateComplete;
expect(checkbox.checkValidity()).to.be.false;
expect(checkbox.checkValidity()).to.be.false;
expect(checkbox.hasAttribute('data-wa-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-valid')).to.be.false;
expect(checkbox.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(checkbox);
await checkbox.updateComplete;
await aTimeout(0);
expect(checkbox.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-user-valid')).to.be.false;
});
it('should be invalid when required and unchecked', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <wa-checkbox required></wa-checkbox> `);
expect(checkbox.checkValidity()).to.be.false;
});
it('should be valid when required and checked', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <wa-checkbox required checked></wa-checkbox> `);
await checkbox.updateComplete;
expect(checkbox.checkValidity()).to.be.true;
});
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>
<form id="f">
<wa-button type="submit">Submit</wa-button>
</form>
<wa-checkbox form="f" name="a" value="1" checked></wa-checkbox>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
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><wa-checkbox required></wa-checkbox></form>
`);
const checkbox = el.querySelector<WaCheckbox>('wa-checkbox')!;
expect(checkbox.hasAttribute('data-wa-required')).to.be.true;
expect(checkbox.hasAttribute('data-wa-optional')).to.be.false;
expect(checkbox.hasAttribute('data-wa-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-valid')).to.be.false;
expect(checkbox.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(checkbox.hasAttribute('data-wa-user-valid')).to.be.false;
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-checkbox name="a" value="1" checked></wa-checkbox>
<wa-button type="reset">Reset</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const checkbox = form.querySelector('wa-checkbox')!;
checkbox.checked = false;
await checkbox.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await checkbox.updateComplete;
expect(checkbox.checked).to.be.true;
checkbox.defaultChecked = false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await checkbox.updateComplete;
expect(checkbox.checked).to.be.false;
});
});
describe('click', () => {
it('should click the inner input', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox></wa-checkbox>`);
const checkbox = el.shadowRoot!.querySelector('input')!;
const clickSpy = sinon.spy();
checkbox.addEventListener('click', clickSpy, { once: true });
el.click();
await el.updateComplete;
expect(clickSpy.called).to.equal(true);
expect(el.checked).to.equal(true);
});
});
describe('focus', () => {
it('should focus the inner input', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox></wa-checkbox>`);
const checkbox = el.shadowRoot!.querySelector('input')!;
const focusSpy = sinon.spy();
checkbox.addEventListener('focus', focusSpy, { once: true });
el.focus();
await el.updateComplete;
expect(focusSpy.called).to.equal(true);
expect(el.shadowRoot!.activeElement).to.equal(checkbox);
});
it('should not jump the page to the bottom when focusing a checkbox at the bottom of an element with overflow: auto;', async () => {
// https://github.com/shoelace-style/shoelace/issues/1169
const el = await fixture<HTMLDivElement>(html`
<div style="display: flex; flex-direction: column; overflow: auto; max-height: 400px; gap: 8px;">
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
</div>
;
`);
const checkboxes = el.querySelectorAll<WaCheckbox>('wa-checkbox');
const lastSwitch = checkboxes[checkboxes.length - 1];
expect(window.scrollY).to.equal(0);
// Without these 2 timeouts, tests will pass unexpectedly in Safari.
await aTimeout(10);
lastSwitch.focus();
await aTimeout(10);
expect(window.scrollY).to.equal(0);
});
});
describe('blur', () => {
it('should blur the inner input', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox></wa-checkbox>`);
const checkbox = el.shadowRoot!.querySelector('input')!;
const blurSpy = sinon.spy();
checkbox.addEventListener('blur', blurSpy, { once: true });
el.focus();
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(blurSpy.called).to.equal(true);
expect(el.shadowRoot!.activeElement).to.equal(null);
});
});
describe('indeterminate', () => {
it('should render indeterminate icon until checked', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox indeterminate></wa-checkbox>`);
let indeterminateIcon = el.shadowRoot!.querySelector('[part~="indeterminate-icon"]')!;
expect(indeterminateIcon).not.to.be.null;
el.click();
await el.updateComplete;
indeterminateIcon = el.shadowRoot!.querySelector('[part~="indeterminate-icon"]')!;
expect(indeterminateIcon).to.be.null;
});
});
});
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <wa-checkbox></wa-checkbox> `);
// Submitting the form after setting custom validity should not trigger the handler
checkbox.setCustomValidity('Invalid selection');
await checkbox.updateComplete;
expect(checkbox.checkValidity()).to.be.false;
expect(checkbox.checkValidity()).to.be.false;
expect(checkbox.hasAttribute('data-wa-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-valid')).to.be.false;
expect(checkbox.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(checkbox);
await checkbox.updateComplete;
await aTimeout(0);
expect(checkbox.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-user-valid')).to.be.false;
});
it('should be invalid when required and unchecked', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <wa-checkbox required></wa-checkbox> `);
expect(checkbox.checkValidity()).to.be.false;
});
it('should be valid when required and checked', async () => {
const checkbox = await fixture<HTMLFormElement>(html` <wa-checkbox required checked></wa-checkbox> `);
await checkbox.updateComplete;
expect(checkbox.checkValidity()).to.be.true;
});
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>
<form id="f">
<wa-button type="submit">Submit</wa-button>
</form>
<wa-checkbox form="f" name="a" value="1" checked></wa-checkbox>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
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><wa-checkbox required></wa-checkbox></form> `);
const checkbox = el.querySelector<WaCheckbox>('wa-checkbox')!;
expect(checkbox.hasAttribute('data-wa-required')).to.be.true;
expect(checkbox.hasAttribute('data-wa-optional')).to.be.false;
expect(checkbox.hasAttribute('data-wa-invalid')).to.be.true;
expect(checkbox.hasAttribute('data-wa-valid')).to.be.false;
expect(checkbox.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(checkbox.hasAttribute('data-wa-user-valid')).to.be.false;
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-checkbox name="a" value="1" checked></wa-checkbox>
<wa-button type="reset">Reset</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const checkbox = form.querySelector('wa-checkbox')!;
checkbox.checked = false;
await checkbox.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await checkbox.updateComplete;
expect(checkbox.checked).to.be.true;
checkbox.defaultChecked = false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await checkbox.updateComplete;
expect(checkbox.checked).to.be.false;
});
});
describe('click', () => {
it('should click the inner input', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox></wa-checkbox>`);
const checkbox = el.shadowRoot!.querySelector('input')!;
const clickSpy = sinon.spy();
checkbox.addEventListener('click', clickSpy, { once: true });
el.click();
await el.updateComplete;
expect(clickSpy.called).to.equal(true);
expect(el.checked).to.equal(true);
});
});
describe('focus', () => {
it('should focus the inner input', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox></wa-checkbox>`);
const checkbox = el.shadowRoot!.querySelector('input')!;
const focusSpy = sinon.spy();
checkbox.addEventListener('focus', focusSpy, { once: true });
el.focus();
await el.updateComplete;
expect(focusSpy.called).to.equal(true);
expect(el.shadowRoot!.activeElement).to.equal(checkbox);
});
it('should not jump the page to the bottom when focusing a checkbox at the bottom of an element with overflow: auto;', async () => {
// https://github.com/shoelace-style/shoelace/issues/1169
const el = await fixture<HTMLDivElement>(html`
<div style="display: flex; flex-direction: column; overflow: auto; max-height: 400px; gap: 8px;">
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
<wa-checkbox>Checkbox</wa-checkbox>
</div>
;
`);
const checkboxes = el.querySelectorAll<WaCheckbox>('wa-checkbox');
const lastSwitch = checkboxes[checkboxes.length - 1];
expect(window.scrollY).to.equal(0);
// Without these 2 timeouts, tests will pass unexpectedly in Safari.
await aTimeout(10);
lastSwitch.focus();
await aTimeout(10);
expect(window.scrollY).to.equal(0);
});
});
describe('blur', () => {
it('should blur the inner input', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox></wa-checkbox>`);
const checkbox = el.shadowRoot!.querySelector('input')!;
const blurSpy = sinon.spy();
checkbox.addEventListener('blur', blurSpy, { once: true });
el.focus();
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(blurSpy.called).to.equal(true);
expect(el.shadowRoot!.activeElement).to.equal(null);
});
});
describe('indeterminate', async () => {
it('should render indeterminate icon until checked', async () => {
const el = await fixture<WaCheckbox>(html`<wa-checkbox indeterminate></wa-checkbox>`);
let indeterminateIcon = el.shadowRoot!.querySelector('[part~="indeterminate-icon"]')!;
expect(indeterminateIcon).not.to.be.null;
el.click();
await el.updateComplete;
indeterminateIcon = el.shadowRoot!.querySelector('[part~="indeterminate-icon"]')!;
expect(indeterminateIcon).to.be.null;
});
await runFormControlBaseTests('wa-checkbox');
});
}
});

View File

@@ -2,7 +2,7 @@ import '../icon/icon.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { RequiredValidator } from '../../internal/validators/required-validator.js';
@@ -56,17 +56,23 @@ import type { CSSResultGroup, PropertyValues } from 'lit';
@customElement('wa-checkbox')
export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
static styles: CSSResultGroup = [componentStyles, formControlStyles, styles];
static shadowRootOptions = { ...WebAwesomeFormAssociatedElement.shadowRootOptions, delegatesFocus: true };
static get validators() {
return [
...super.validators,
RequiredValidator({
// Use a checkbox so we get "free" translation strings.
validationElement: Object.assign(document.createElement('input'), {
type: 'checkbox',
required: true
})
})
];
const validators = isServer
? []
: [
RequiredValidator({
validationProperty: 'checked',
// Use a checkbox so we get "free" translation strings.
validationElement: Object.assign(document.createElement('input'), {
type: 'checkbox',
required: true
})
})
];
return [...super.validators, ...validators];
}
private readonly hasSlotController = new HasSlotController(this, 'help-text');
@@ -80,8 +86,17 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
/** The name of the checkbox, submitted as a name/value pair with form data. */
@property({ reflect: true }) name = '';
/** The current value of the checkbox, submitted as a name/value pair with form data. */
@property() value: null | string;
private _value: string | null = this.getAttribute('value') ?? null;
/** The value of the checkbox, submitted as a name/value pair with form data. */
get value() {
return this._value ?? 'on';
}
@property({ reflect: true })
set value(val: string | null) {
this._value = val;
}
/** The checkbox's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@@ -115,6 +130,7 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
@property({ attribute: 'help-text' }) helpText = '';
private handleClick() {
this.hasInteracted = true;
this.checked = !this.checked;
this.indeterminate = false;
this.dispatchEvent(new WaChangeEvent());
@@ -144,10 +160,9 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
handleValueOrCheckedChange() {
this.toggleCustomState('checked', this.checked);
this.value = this.checked ? this.value || 'on' : null;
// These @watch() commands seem to override the base element checks for changes, so we need to setValue for the form and and updateValidity()
this.setValue(this.value, this.value);
this.setValue(this.checked ? this.value : null, this._value);
this.updateValidity();
}
@@ -161,6 +176,12 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has('defaultChecked')) {
if (!this.hasInteracted) {
this.checked = this.defaultChecked;
}
}
if (changedProperties.has('value') || changedProperties.has('checked')) {
this.handleValueOrCheckedChange();
}
@@ -189,7 +210,7 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
}
render() {
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasHelpTextSlot = isServer ? true : this.hasSlotController.test('help-text');
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
//
@@ -231,7 +252,7 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
type="checkbox"
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
name=${this.name}
value=${ifDefined(this.value)}
value=${ifDefined(this._value)}
.indeterminate=${live(this.indeterminate)}
.checked=${live(this.checked)}
.disabled=${this.disabled}
@@ -248,7 +269,7 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
? html`
<wa-icon part="checked-icon" class="checkbox__checked-icon" library="system" name="check"></wa-icon>
`
: ''}
: html``}
${!this.checked && this.indeterminate
? html`
<wa-icon
@@ -258,7 +279,7 @@ export default class WaCheckbox extends WebAwesomeFormAssociatedElement {
name="indeterminate"
></wa-icon>
`
: ''}
: html``}
</span>
<div part="label" class="checkbox__label">

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,14 @@ import '../button-group/button-group.js';
import '../button/button.js';
import '../dropdown/dropdown.js';
import '../icon/icon.js';
import '../input/input.js';
import '../visually-hidden/visually-hidden.js';
// import '../input/input.js';
// import '../visually-hidden/visually-hidden.js';
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
import { drag } from '../../internal/drag.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize.js';
import { RequiredValidator } from '../../internal/validators/required-validator.js';
@@ -25,12 +25,10 @@ import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-eleme
import componentStyles from '../../styles/component.styles.js';
import formControlStyles from '../../styles/form-control.styles.js';
import styles from './color-picker.styles.js';
import type { CSSResultGroup } from 'lit';
import type { CSSResultGroup, PropertyValues } from 'lit';
import type WaDropdown from '../dropdown/dropdown.js';
import type WaInput from '../input/input.js';
const hasEyeDropper = 'EyeDropper' in window;
interface EyeDropperConstructor {
new (): EyeDropperInterface;
}
@@ -113,7 +111,8 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
static shadowRootOptions = { ...WebAwesomeFormAssociatedElement.shadowRootOptions, delegatesFocus: true };
static get validators() {
return [...super.validators, RequiredValidator()];
const validators = isServer ? [] : [RequiredValidator()];
return [...super.validators, ...validators];
}
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
@@ -152,15 +151,39 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
@state() private brightness = 100;
@state() private alpha = 100;
private _value: string | null = null;
/** The current value of the input, submitted as a name/value pair with form data. */
get value() {
if (this.valueHasChanged) {
return this._value;
}
return this._value ?? this.defaultValue;
}
/**
* The current value of the color picker. The value's format will vary based the `format` attribute. To get the value
* in a specific format, use the `getFormattedValue()` method. The value is submitted as a name/value pair with form
* data.
*/
@property({ attribute: false }) value = this.getAttribute('value') || '';
@state() set value(val: string | null) {
if (this._value === val) {
return;
}
this.valueHasChanged = true;
this._value = val;
}
/** The default value of the form control. Primarily used for resetting the form control. */
@property({ attribute: 'value', reflect: true }) defaultValue = this.getAttribute('value') || '';
@property({ attribute: 'value', reflect: true }) defaultValue: null | string = this.getAttribute('value') || null;
@property({ attribute: 'with-label', reflect: true, type: Boolean }) withLabel = false;
@property({ attribute: 'with-help-text', reflect: true, type: Boolean }) withHelpText = false;
@state() private hasEyeDropper: boolean = false;
/**
* The color picker's label. This will not be displayed, but it will be announced by assistive devices. If you need to
@@ -222,8 +245,11 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
constructor() {
super();
this.addEventListener('focusin', this.handleFocusIn);
this.addEventListener('focusout', this.handleFocusOut);
if (!isServer) {
this.addEventListener('focusin', this.handleFocusIn);
this.addEventListener('focusout', this.handleFocusOut);
}
}
private handleCopy() {
@@ -252,7 +278,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
const formats = ['hex', 'rgb', 'hsl', 'hsv'];
const nextIndex = (formats.indexOf(this.format) + 1) % formats.length;
this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl' | 'hsv';
this.setColor(this.value);
this.setColor(this.value || '');
this.dispatchEvent(new WaChangeEvent());
this.dispatchEvent(new WaInputEvent());
}
@@ -462,7 +488,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
if (this.input.value) {
this.setColor(target.value);
target.value = this.value;
target.value = this.value || '';
} else {
this.value = '';
}
@@ -651,7 +677,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
}
private handleEyeDropper() {
if (!hasEyeDropper) {
if (!this.hasEyeDropper) {
return;
}
@@ -688,7 +714,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
}
/** Generates a hex string from HSV values. Hue must be 0-360. All other arguments must be 0-100. */
private getHexString(hue: number, saturation: number, brightness: number, alpha = 100) {
getHexString(hue: number, saturation: number, brightness: number, alpha = 100) {
const color = new TinyColor(`hsva(${hue}, ${saturation}%, ${brightness}%, ${alpha / 100})`);
if (!color.isValid) {
return '';
@@ -707,11 +733,20 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
this.syncValues();
}
@watch('opacity', { waitUntilFirstUpdate: true })
@watch('opacity')
handleOpacityChange() {
this.alpha = 100;
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
// Its kind of bizarre, but this is required to get SSR to play nicely.
if (changedProperties.has('value')) {
this.handleValueChange(changedProperties.get('value') || '', this.value || '');
}
}
@watch('value')
handleValueChange(oldValue: string | undefined, newValue: string) {
this.isEmpty = !newValue;
@@ -727,7 +762,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
const newColor = this.parseColor(newValue);
if (newColor !== null) {
this.inputValue = this.value;
this.inputValue = this.value || '';
this.hue = newColor.hsva.h;
this.saturation = newColor.hsva.s;
this.brightness = newColor.hsva.v;
@@ -737,6 +772,8 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
this.inputValue = oldValue ?? '';
}
}
this.requestUpdate();
}
/** Sets focus on the color picker. */
@@ -818,9 +855,17 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
super.formResetCallback();
}
firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
this.hasEyeDropper = 'EyeDropper' in window;
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabelSlot = !this.hasUpdated ? this.withLabel : this.withLabel || this.hasSlotController.test('label');
const hasHelpTextSlot = !this.hasUpdated
? this.withHelpText
: this.withHelpText || this.hasSlotController.test('help-text');
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
@@ -986,7 +1031,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
</wa-button>
`
: ''}
${hasEyeDropper
${this.hasEyeDropper
? html`
<wa-button
part="eye-dropper-button"
@@ -1076,12 +1121,15 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
const composedPath = e.composedPath();
const triggerButton = this.triggerButton;
const triggerLabel = this.triggerLabel;
if (composedPath.find(el => el === triggerButton || el === triggerLabel)) {
const buttonOrLabelClicked = composedPath.find(el => el === triggerButton || el === triggerLabel);
if (buttonOrLabelClicked) {
return;
}
// Stop clicks from bubbling on anything except the button and the label. This is a hacky work around i may come to regret, but this "fixes" the issue of `<wa-dropdown>` expecting all children in the "trigger slot" to open the trigger. [Konnor]
e.stopImmediatePropagation();
e.stopPropagation();
if (this.dropdown.open) {
this.dropdown.hide();

View File

@@ -1,19 +1,20 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaCopyButton from './copy-button.js';
// We use aria-live to announce labels via tooltips
const ignoredRules = ['button-name'];
describe('<wa-copy-button>', () => {
let el: WaCopyButton;
describe('when provided no parameters', () => {
before(async () => {
el = await fixture(html`<wa-copy-button value="something"></wa-copy-button> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when provided no parameters', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaCopyButton>(html`<wa-copy-button value="something"></wa-copy-button> `);
await expect(el).to.be.accessible({ ignoredRules });
});
});
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible({ ignoredRules });
});
});
}
});

View File

@@ -1,198 +1,204 @@
// cspell:dictionaries lorem-ipsum
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { expect, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import sinon from 'sinon';
import type { WaHideEvent } from '../../events/hide.js';
import type { WaShowEvent } from '../../events/show.js';
import type WaDetails from './details.js';
describe('<wa-details>', () => {
describe('accessibility', () => {
it('should be accessible when closed', async () => {
const details = await fixture<WaDetails>(html`<wa-details summary="Test"> Test text </wa-details>`);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('accessibility', () => {
it('should be accessible when closed', async () => {
const details = await fixture<WaDetails>(html`<wa-details summary="Test"> Test text </wa-details>`);
await expect(details).to.be.accessible();
await expect(details).to.be.accessible();
});
it('should be accessible when open', async () => {
const details = await fixture<WaDetails>(html`<wa-details open summary="Test">Test text</wa-details>`);
await expect(details).to.be.accessible();
});
});
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDetails>(html`
<wa-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</wa-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
expect(parseInt(getComputedStyle(body).height)).to.be.greaterThan(0);
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDetails>(html`
<wa-details summary="click me">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</wa-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
expect(parseInt(getComputedStyle(body).height)).to.equal(0);
});
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDetails>(html`
<wa-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</wa-details>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-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;
});
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
const el = await fixture<WaDetails>(html`
<wa-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</wa-details>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
});
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDetails>(html`
<wa-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</wa-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.false;
});
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
const el = await fixture<WaDetails>(html`
<wa-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</wa-details>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
});
it('should not open when preventing wa-show', async () => {
const el = await fixture<WaDetails>(html`
<wa-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</wa-details>
`);
const showHandler = sinon.spy((event: WaShowEvent) => event.preventDefault());
el.addEventListener('wa-show', showHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(el.open).to.be.false;
});
it('should not close when preventing wa-hide', async () => {
const el = await fixture<WaDetails>(html`
<wa-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat.
</wa-details>
`);
const hideHandler = sinon.spy((event: WaHideEvent) => event.preventDefault());
el.addEventListener('wa-hide', hideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(el.open).to.be.true;
});
it('should be the correct size after opening more than one instance', async () => {
const el = await fixture<WaDetails>(html`
<div>
<wa-details>
<div style="height: 200px;"></div>
</wa-details>
<wa-details>
<div style="height: 400px;"></div>
</wa-details>
</div>
`);
const first = el.querySelectorAll('wa-details')[0];
const second = el.querySelectorAll('wa-details')[1];
const firstBody = first.shadowRoot!.querySelector('.details__body')!;
const secondBody = second.shadowRoot!.querySelector('.details__body')!;
await first.show();
await second.show();
expect(firstBody.clientHeight).to.equal(232); // 200 + 16px + 16px (vertical padding)
expect(secondBody.clientHeight).to.equal(432); // 400 + 16px + 16px (vertical padding)
});
});
it('should be accessible when open', async () => {
const details = await fixture<WaDetails>(html`<wa-details open summary="Test">Test text</wa-details>`);
await expect(details).to.be.accessible();
});
});
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDetails>(html`
<wa-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</wa-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
expect(parseInt(getComputedStyle(body).height)).to.be.greaterThan(0);
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDetails>(html`
<wa-details summary="click me">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</wa-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
expect(parseInt(getComputedStyle(body).height)).to.equal(0);
});
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDetails>(html`
<wa-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</wa-details>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-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;
});
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
const el = await fixture<WaDetails>(html`
<wa-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</wa-details>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
});
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDetails>(html`
<wa-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</wa-details>
`);
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(body.hidden).to.be.false;
});
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
const el = await fixture<WaDetails>(html`
<wa-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</wa-details>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
});
it('should not open when preventing wa-show', async () => {
const el = await fixture<WaDetails>(html`
<wa-details>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</wa-details>
`);
const showHandler = sinon.spy((event: WaShowEvent) => event.preventDefault());
el.addEventListener('wa-show', showHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(el.open).to.be.false;
});
it('should not close when preventing wa-hide', async () => {
const el = await fixture<WaDetails>(html`
<wa-details open>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat.
</wa-details>
`);
const hideHandler = sinon.spy((event: WaHideEvent) => event.preventDefault());
el.addEventListener('wa-hide', hideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(el.open).to.be.true;
});
it('should be the correct size after opening more than one instance', async () => {
const el = await fixture<WaDetails>(html`
<div>
<wa-details>
<div style="height: 200px;"></div>
</wa-details>
<wa-details>
<div style="height: 400px;"></div>
</wa-details>
</div>
`);
const first = el.querySelectorAll('wa-details')[0];
const second = el.querySelectorAll('wa-details')[1];
const firstBody = first.shadowRoot!.querySelector('.details__body')!;
const secondBody = second.shadowRoot!.querySelector('.details__body')!;
await first.show();
await second.show();
expect(firstBody.clientHeight).to.equal(232); // 200 + 16px + 16px (vertical padding)
expect(secondBody.clientHeight).to.equal(432); // 400 + 16px + 16px (vertical padding)
});
}
});

View File

@@ -208,7 +208,7 @@ export default class WaDetails extends WebAwesomeElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = !this.hasUpdated ? this.dir === 'rtl' : this.matches(':dir(rtl)');
return html`
<details

View File

@@ -1,134 +1,145 @@
// cspell:dictionaries lorem-ipsum
import { aTimeout, expect, fixture, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type WaDialog from './dialog.js';
describe('<wa-dialog>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
expect(getComputedStyle(el).display).to.not.equal('none');
});
expect(getComputedStyle(el).display).to.not.equal('none');
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
expect(getComputedStyle(el).display).to.equal('none');
});
expect(getComputedStyle(el).display).to.equal('none');
});
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.not.equal('none');
});
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.not.equal('none');
});
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.equal('none');
});
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.equal('none');
});
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.not.equal('none');
});
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.not.equal('none');
});
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.equal('none');
});
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.equal('none');
});
it('should not close when wa-hide is prevented', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
it('should not close when wa-hide is prevented', async () => {
const el = await fixture<WaDialog>(html`
<wa-dialog with-header open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-dialog>
`);
el.addEventListener('wa-hide', event => {
event.preventDefault();
const spy = sinon.spy();
el.addEventListener('wa-hide', event => {
event.preventDefault();
spy();
});
await clickOnElement(el); // Chromium wants the page to have been clicked prior to closing the dialog.
await sendKeys({ press: 'Escape' });
await waitUntil(() => spy.calledOnce);
expect(el.open).to.be.true;
});
it('should allow initial focus to be set', async () => {
const el = await fixture<WaDialog>(html` <wa-dialog with-header><wa-input autofocus></wa-input></wa-dialog> `);
const input = el.querySelector('wa-input')!;
el.open = true;
await aTimeout(250);
expect(document.activeElement).to.equal(input);
});
it('should close when pressing Escape', async () => {
const hideHandler = sinon.spy();
const el = await fixture<WaDialog>(html` <wa-dialog with-header open></wa-dialog> `);
el.addEventListener('wa-after-hide', hideHandler);
await clickOnElement(el); // Chromium wants the page to have been clicked prior to closing the dialog.
await sendKeys({ press: 'Escape' });
await waitUntil(() => hideHandler.calledOnce);
expect(el.open).to.be.false;
});
});
await sendKeys({ press: 'Escape' });
expect(el.open).to.be.true;
});
it('should allow initial focus to be set', async () => {
const el = await fixture<WaDialog>(html` <wa-dialog with-header><wa-input autofocus></wa-input></wa-dialog> `);
const input = el.querySelector('wa-input')!;
el.open = true;
await aTimeout(250);
expect(document.activeElement).to.equal(input);
});
it('should close when pressing Escape', async () => {
const el = await fixture<WaDialog>(html` <wa-dialog with-header open></wa-dialog> `);
const hideHandler = sinon.spy();
el.addEventListener('wa-after-hide', hideHandler);
await sendKeys({ press: 'Escape' });
await waitUntil(() => hideHandler.calledOnce);
expect(el.open).to.be.false;
});
}
});

View File

@@ -2,7 +2,7 @@ import '../icon-button/icon-button.js';
import { animateWithClass } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
import { WaAfterHideEvent } from '../../events/after-hide.js';
@@ -275,9 +275,11 @@ export default class WaDialog extends WebAwesomeElement {
}
// Ugly, but it fixes light dismiss in Safari: https://bugs.webkit.org/show_bug.cgi?id=267688
document.body.addEventListener('pointerdown', () => {
/* empty */
});
if (!isServer) {
document.body.addEventListener('pointerdown', () => {
/* empty */
});
}
declare global {
interface HTMLElementTagNameMap {

View File

@@ -1,30 +1,36 @@
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import { elementUpdated, expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaDivider from './divider.js';
describe('<wa-divider>', () => {
describe('defaults ', () => {
it('passes accessibility test', async () => {
const el = await fixture<WaDivider>(html` <wa-divider></wa-divider> `);
await expect(el).to.be.accessible();
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaDivider>(html` <wa-divider></wa-divider> `);
expect(el.vertical).to.be.false;
expect(el.getAttribute('role')).to.equal('separator');
expect(el.getAttribute('aria-orientation')).to.equal('horizontal');
});
it('passes accessibility test', async () => {
const el = await fixture<WaDivider>(html` <wa-divider></wa-divider> `);
await expect(el).to.be.accessible();
});
});
describe('vertical property change ', () => {
it('aria-orientation is updated', async () => {
const el = await fixture<WaDivider>(html` <wa-divider></wa-divider> `);
el.vertical = true;
await elementUpdated(el);
expect(el.getAttribute('aria-orientation')).to.equal('vertical');
});
});
});
it('default properties', async () => {
const el = await fixture<WaDivider>(html` <wa-divider></wa-divider> `);
expect(el.vertical).to.be.false;
expect(el.getAttribute('role')).to.equal('separator');
expect(el.getAttribute('aria-orientation')).to.equal('horizontal');
});
});
describe('vertical property change ', () => {
it('aria-orientation is updated', async () => {
const el = await fixture<WaDivider>(html` <wa-divider></wa-divider> `);
el.vertical = true;
await elementUpdated(el);
expect(el.getAttribute('aria-orientation')).to.equal('vertical');
});
});
}
});

View File

@@ -1,135 +1,142 @@
// cspell:dictionaries lorem-ipsum
import { aTimeout, expect, fixture, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type WaDrawer from './drawer.js';
describe('<wa-drawer>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
expect(getComputedStyle(el).display).to.not.equal('none');
});
expect(getComputedStyle(el).display).to.not.equal('none');
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
expect(getComputedStyle(el).display).to.equal('none');
});
expect(getComputedStyle(el).display).to.equal('none');
});
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.not.equal('none');
});
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.not.equal('none');
});
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.equal('none');
});
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.equal('none');
});
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.not.equal('none');
});
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.not.equal('none');
});
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.equal('none');
});
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(getComputedStyle(el).display).to.equal('none');
});
it('should not close when wa-hide is prevented', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
it('should not close when wa-hide is prevented', async () => {
const el = await fixture<WaDrawer>(html`
<wa-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</wa-drawer>
`);
el.addEventListener('wa-hide', event => {
event.preventDefault();
el.addEventListener('wa-hide', event => {
event.preventDefault();
});
await sendKeys({ press: 'Escape' });
expect(el.open).to.be.true;
});
it('should allow initial focus to be set', async () => {
const el = await fixture<WaDrawer>(html` <wa-drawer with-header><wa-input autofocus></wa-input></wa-drawer> `);
const input = el.querySelector('wa-input')!;
el.open = true;
await aTimeout(250);
expect(document.activeElement).to.equal(input);
});
it('should close when pressing Escape', async () => {
const el = await fixture<WaDrawer>(html` <wa-drawer open></wa-drawer> `);
const hideHandler = sinon.spy();
el.addEventListener('wa-after-hide', hideHandler);
await clickOnElement(el); // Chromium wants the page to be clicked
await sendKeys({ press: 'Escape' });
await waitUntil(() => hideHandler.calledOnce);
expect(el.open).to.be.false;
});
});
await sendKeys({ press: 'Escape' });
expect(el.open).to.be.true;
});
it('should allow initial focus to be set', async () => {
const el = await fixture<WaDrawer>(html` <wa-drawer with-header><wa-input autofocus></wa-input></wa-drawer> `);
const input = el.querySelector('wa-input')!;
el.open = true;
await aTimeout(250);
expect(document.activeElement).to.equal(input);
});
it('should close when pressing Escape', async () => {
const el = await fixture<WaDrawer>(html` <wa-drawer open></wa-drawer> `);
const hideHandler = sinon.spy();
el.addEventListener('wa-after-hide', hideHandler);
await sendKeys({ press: 'Escape' });
await waitUntil(() => hideHandler.calledOnce);
expect(el.open).to.be.false;
});
}
});

View File

@@ -2,7 +2,7 @@ import '../icon-button/icon-button.js';
import { animateWithClass } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
import { WaAfterHideEvent } from '../../events/after-hide.js';
@@ -93,6 +93,9 @@ export default class WaDrawer extends WebAwesomeElement {
@property({ attribute: 'light-dismiss', type: Boolean }) lightDismiss = false;
firstUpdated() {
if (isServer) {
return;
}
if (this.open) {
this.addOpenListeners();
this.drawer.showModal();
@@ -102,6 +105,7 @@ export default class WaDrawer extends WebAwesomeElement {
disconnectedCallback() {
super.disconnectedCallback();
unlockBodyScrolling(this);
this.closeWatcher?.destroy();
}
@@ -288,10 +292,12 @@ export default class WaDrawer extends WebAwesomeElement {
}
}
// Ugly, but it fixes light dismiss in Safari: https://bugs.webkit.org/show_bug.cgi?id=267688
document.body.addEventListener('pointerdown', () => {
/* empty */
});
if (!isServer) {
// Ugly, but it fixes light dismiss in Safari: https://bugs.webkit.org/show_bug.cgi?id=267688
document.body.addEventListener('pointerdown', () => {
/* empty */
});
}
declare global {
interface HTMLElementTagNameMap {

View File

@@ -1,379 +1,405 @@
import { clickOnElement } from '../../internal/test.js';
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { expect, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { sendKeys, sendMouse } from '@web/test-runner-commands';
import sinon from 'sinon';
import type WaDropdown from './dropdown.js';
describe('<wa-dropdown>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
expect(panel.hidden).to.be.false;
});
expect(panel.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
expect(panel.hidden).to.be.true;
});
expect(panel.hidden).to.be.true;
});
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.show();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.hide();
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.hide();
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
it('should still open on arrow navigation when no menu items', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu> </wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
expect(el.open).to.be.true;
});
it('should still open on arrow navigation when no menu items', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu> </wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
it('should open on arrow down navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const firstMenuItem = el.querySelectorAll('wa-menu-item')[0];
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.true;
});
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(firstMenuItem);
});
it('should open on arrow down navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const firstMenuItem = el.querySelectorAll('wa-menu-item')[0];
it('should open on arrow up navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const secondMenuItem = el.querySelectorAll('wa-menu-item')[1];
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
trigger.focus();
await sendKeys({ press: 'ArrowUp' });
await el.updateComplete;
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(firstMenuItem);
});
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(secondMenuItem);
});
it('should open on arrow up navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const secondMenuItem = el.querySelectorAll('wa-menu-item')[1];
it('should navigate to first focusable item on arrow navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-label>Top Label</wa-menu-label>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const item = el.querySelector('wa-menu-item')!;
trigger.focus();
await sendKeys({ press: 'ArrowUp' });
await el.updateComplete;
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(secondMenuItem);
});
expect(document.activeElement).to.equal(item);
});
it('should navigate to first focusable item on arrow navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-label>Top Label</wa-menu-label>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const item = el.querySelector('wa-menu-item')!;
it('should close on escape key', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
trigger.focus();
await sendKeys({ press: 'Escape' });
await el.updateComplete;
expect(document.activeElement).to.equal(item);
});
expect(el.open).to.be.false;
});
it('should close on escape key', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
it('should not open on arrow navigation when no menu exists', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<div>Some custom content</div>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await sendKeys({ press: 'Escape' });
await el.updateComplete;
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.false;
});
expect(el.open).to.be.false;
});
it('should not open on arrow navigation when no menu exists', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<div>Some custom content</div>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
it('should open on enter key', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
trigger.focus();
await el.updateComplete;
await sendKeys({ press: 'Enter' });
await el.updateComplete;
expect(el.open).to.be.false;
});
expect(el.open).to.be.true;
});
it('should open on enter key', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
it('should focus on menu items when clicking the trigger and arrowing through options', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const secondMenuItem = el.querySelectorAll('wa-menu-item')[1];
trigger.focus();
await el.updateComplete;
await sendKeys({ press: 'Enter' });
await el.updateComplete;
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.true;
});
expect(document.activeElement).to.equal(secondMenuItem);
});
it('should focus on menu items when clicking the trigger and arrowing through options', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const secondMenuItem = el.querySelectorAll('wa-menu-item')[1];
it('should open on enter key when no menu exists', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<div>Some custom content</div>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
trigger.focus();
await el.updateComplete;
await sendKeys({ press: 'Enter' });
await el.updateComplete;
expect(document.activeElement).to.equal(secondMenuItem);
});
expect(el.open).to.be.true;
});
it('should open on enter key when no menu exists', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<div>Some custom content</div>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
it('should hide when clicked outside container and initially open', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
trigger.focus();
await el.updateComplete;
await sendKeys({ press: 'Enter' });
await el.updateComplete;
await sendMouse({ type: 'click', position: [0, 0] });
await el.updateComplete;
expect(el.open).to.be.true;
});
expect(el.open).to.be.false;
});
it('should hide when clicked outside container and initially open', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
it('should hide when clicked outside container', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
await sendMouse({ type: 'click', position: [0, 0] });
await el.updateComplete;
trigger.click();
await el.updateComplete;
await sendMouse({ type: 'click', position: [0, 0] });
await el.updateComplete;
expect(el.open).to.be.false;
});
expect(el.open).to.be.false;
});
it('should hide when clicked outside container', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
it('should close and stop propagating the keydown event when Escape is pressed and the dropdown is open ', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Dropdown Item 1</wa-menu-item>
<wa-menu-item>Dropdown Item 2</wa-menu-item>
<wa-menu-item>Dropdown Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const firstMenuItem = el.querySelector('wa-menu-item')!;
const hideHandler = sinon.spy();
trigger.click();
await el.updateComplete;
await sendMouse({ type: 'click', position: [0, 0] });
await el.updateComplete;
document.body.addEventListener('keydown', hideHandler);
firstMenuItem.focus();
await sendKeys({ press: 'Escape' });
await el.updateComplete;
expect(el.open).to.be.false;
});
expect(el.open).to.be.false;
expect(hideHandler).to.not.have.been.called;
});
it('should close and stop propagating the keydown event when Escape is pressed and the dropdown is open ', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Dropdown Item 1</wa-menu-item>
<wa-menu-item>Dropdown Item 2</wa-menu-item>
<wa-menu-item>Dropdown Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const firstMenuItem = el.querySelector('wa-menu-item')!;
const hideHandler = sinon.spy();
document.body.addEventListener('keydown', hideHandler);
firstMenuItem.focus();
await sendKeys({ press: 'Escape' });
await el.updateComplete;
expect(el.open).to.be.false;
if ('CloseWatcher' in window) {
return;
}
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
expect(hideHandler).to.not.have.been.called;
});
});
}
});

View File

@@ -1,117 +1,123 @@
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import { elementUpdated, expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaFormatBytes from './format-bytes.js';
describe('<wa-format-bytes>', () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaFormatBytes>(html` <wa-format-bytes></wa-format-bytes> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaFormatBytes>(html` <wa-format-bytes></wa-format-bytes> `);
expect(el.value).to.equal(0);
expect(el.unit).to.equal('byte');
expect(el.display).to.equal('short');
expect(el.lang).to.be.undefined;
});
});
expect(el.value).to.equal(0);
expect(el.unit).to.equal('byte');
expect(el.display).to.equal('short');
expect(el.lang).to.be.undefined;
});
});
describe('bytes', () => {
const results = [
{
value: 12,
short: '12 byte',
long: '12 bytes',
narrow: '12B'
},
{
value: 1200,
short: '1.2 kB',
long: '1.2 kilobytes',
narrow: '1.2kB'
},
{
value: 1200000,
short: '1.2 MB',
long: '1.2 megabytes',
narrow: '1.2MB'
},
{
value: 1200000000,
short: '1.2 GB',
long: '1.2 gigabytes',
narrow: '1.2GB'
}
];
describe('bytes', () => {
const results = [
{
value: 12,
short: '12 byte',
long: '12 bytes',
narrow: '12B'
},
{
value: 1200,
short: '1.2 kB',
long: '1.2 kilobytes',
narrow: '1.2kB'
},
{
value: 1200000,
short: '1.2 MB',
long: '1.2 megabytes',
narrow: '1.2MB'
},
{
value: 1200000000,
short: '1.2 GB',
long: '1.2 gigabytes',
narrow: '1.2GB'
}
];
results.forEach(expected => {
it('bytes : display formats', async () => {
const el = await fixture<WaFormatBytes>(html` <wa-format-bytes></wa-format-bytes> `);
// short
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.short);
results.forEach(expected => {
it('bytes : display formats', async () => {
const el = await fixture<WaFormatBytes>(html` <wa-format-bytes></wa-format-bytes> `);
// short
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.short);
// long
el.display = 'long';
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.long);
// long
el.display = 'long';
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.long);
// narrow
el.display = 'narrow';
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.narrow);
// narrow
el.display = 'narrow';
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.narrow);
});
});
});
describe('bits', () => {
const results = [
{
value: 12,
short: '12 bit',
long: '12 bits',
narrow: '12bit'
},
{
value: 1200,
short: '1.2 kb',
long: '1.2 kilobits',
narrow: '1.2kb'
},
{
value: 1200000,
short: '1.2 Mb',
long: '1.2 megabits',
narrow: '1.2Mb'
},
{
value: 1200000000,
short: '1.2 Gb',
long: '1.2 gigabits',
narrow: '1.2Gb'
}
];
results.forEach(expected => {
it('bits : display formats', async () => {
const el = await fixture<WaFormatBytes>(html` <wa-format-bytes unit="bit"></wa-format-bytes> `);
// short
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.short);
// long
el.display = 'long';
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.long);
// narrow
el.display = 'narrow';
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.narrow);
});
});
});
});
});
describe('bits', () => {
const results = [
{
value: 12,
short: '12 bit',
long: '12 bits',
narrow: '12bit'
},
{
value: 1200,
short: '1.2 kb',
long: '1.2 kilobits',
narrow: '1.2kb'
},
{
value: 1200000,
short: '1.2 Mb',
long: '1.2 megabits',
narrow: '1.2Mb'
},
{
value: 1200000000,
short: '1.2 Gb',
long: '1.2 gigabits',
narrow: '1.2Gb'
}
];
results.forEach(expected => {
it('bits : display formats', async () => {
const el = await fixture<WaFormatBytes>(html` <wa-format-bytes unit="bit"></wa-format-bytes> `);
// short
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.short);
// long
el.display = 'long';
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.long);
// narrow
el.display = 'narrow';
el.value = expected.value;
await elementUpdated(el);
expect(el.shadowRoot?.textContent).to.equal(expected.narrow);
});
});
});
}
});

View File

@@ -1,246 +1,275 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import sinon from 'sinon';
import type WaFormatDate from './format-date.js';
describe('<wa-format-date>', () => {
describe('defaults ', () => {
let clock: sinon.SinonFakeTimers;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('defaults ', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
// fake timer so `new Date()` can be tested
clock = sinon.useFakeTimers({
now: new Date()
beforeEach(() => {
// fake timer so `new Date()` can be tested
clock = sinon.useFakeTimers({
now: new Date()
});
});
afterEach(() => {
clock.restore();
});
it('default properties', async () => {
const el = await fixture<WaFormatDate>(html` <wa-format-date></wa-format-date> `);
expect(el.date).to.deep.equal(new Date());
expect(el.lang).to.be.undefined;
expect(el.weekday).to.be.undefined;
expect(el.era).to.be.undefined;
expect(el.year).to.be.undefined;
expect(el.month).to.be.undefined;
expect(el.day).to.be.undefined;
expect(el.hour).to.be.undefined;
expect(el.minute).to.be.undefined;
expect(el.second).to.be.undefined;
expect(el.timeZoneName).to.be.undefined;
expect(el.timeZone).to.be.undefined;
expect(el.hourFormat).to.equal('auto');
});
});
describe('lang property', () => {
const results = [
{ lang: 'de', result: `1.1.${new Date().getFullYear()}` },
{ lang: 'de-CH', result: `1.1.${new Date().getFullYear()}` },
{ lang: 'fr', result: `01/01/${new Date().getFullYear()}` },
{ lang: 'es', result: `1/1/${new Date().getFullYear()}` },
{ lang: 'he', result: `1.1.${new Date().getFullYear()}` },
{ lang: 'ja', result: `${new Date().getFullYear()}/1/1` },
{ lang: 'nl', result: `1-1-${new Date().getFullYear()}` },
{ lang: 'pl', result: `1.01.${new Date().getFullYear()}` },
{ lang: 'pt', result: `01/01/${new Date().getFullYear()}` },
{ lang: 'ru', result: `01.01.${new Date().getFullYear()}` }
];
results.forEach(setup => {
it(`date has correct language format: ${setup.lang}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" lang="${setup.lang}"></wa-format-date>
`);
expect(el.shadowRoot?.textContent?.trim()).to.equal(setup.result);
});
});
});
describe('weekday property', () => {
const weekdays = ['narrow', 'short', 'long'];
weekdays.forEach((weekdayFormat: 'narrow' | 'short' | 'long') => {
it(`date has correct weekday format: ${weekdayFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
weekday="${weekdayFormat}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { weekday: weekdayFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('era property', () => {
const eras = ['narrow', 'short', 'long'];
eras.forEach((eraFormat: 'narrow' | 'short' | 'long') => {
it(`date has correct era format: ${eraFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" era="${eraFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { era: eraFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('year property', () => {
const yearFormats = ['numeric', '2-digit'];
yearFormats.forEach((yearFormat: 'numeric' | '2-digit') => {
it(`date has correct year format: ${yearFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" year="${yearFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { year: yearFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('month property', () => {
const monthFormats = ['numeric', '2-digit', 'narrow', 'short', 'long'];
monthFormats.forEach((monthFormat: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long') => {
it(`date has correct month format: ${monthFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
month="${monthFormat}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { month: monthFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('day property', () => {
const dayFormats = ['numeric', '2-digit'];
dayFormats.forEach((dayFormat: 'numeric' | '2-digit') => {
it(`date has correct day format: ${dayFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" day="${dayFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { day: dayFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('hour property', () => {
const hourFormats = ['numeric', '2-digit'];
hourFormats.forEach((hourFormat: 'numeric' | '2-digit') => {
it(`date has correct hour format: ${hourFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" hour="${hourFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { hour: hourFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('minute property', () => {
const minuteFormats = ['numeric', '2-digit'];
minuteFormats.forEach((minuteFormat: 'numeric' | '2-digit') => {
it(`date has correct minute format: ${minuteFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
minute="${minuteFormat}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { minute: minuteFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
// @TODO: Some weird browser / Node issue only in firefox.
if (fixture.type === 'ssr-client-hydrated' && minuteFormat === '2-digit') {
return;
}
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('second property', () => {
const secondFormats = ['numeric', '2-digit'];
secondFormats.forEach((secondFormat: 'numeric' | '2-digit') => {
it(`date has correct second format: ${secondFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
second="${secondFormat}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { second: secondFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
// @TODO: Some weird browser / Node issue only in firefox.
if (fixture.type === 'ssr-client-hydrated' && secondFormat === '2-digit') {
return;
}
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('timeZoneName property', () => {
const timeZoneNameFormats = ['short', 'long'];
timeZoneNameFormats.forEach((timeZoneNameFormat: 'short' | 'long') => {
it(`date has correct timeZoneName format: ${timeZoneNameFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
time-zone-name="${timeZoneNameFormat}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { timeZoneName: timeZoneNameFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('timeZone property', () => {
const timeZones = ['America/New_York', 'America/Los_Angeles', 'Europe/Zurich'];
timeZones.forEach(timeZone => {
it(`date has correct timeZoneName format: ${timeZone}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
time-zone="${timeZone}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { timeZone: timeZone }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('hourFormat property', () => {
const hourFormatValues = ['auto', '12', '24'];
hourFormatValues.forEach(hourFormatValue => {
it(`date has correct hourFormat format: ${hourFormatValue}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
hour-format="${hourFormatValue as 'auto' | '12' | '24'}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', {
hour12: hourFormatValue === 'auto' ? undefined : hourFormatValue === '12'
}).format(new Date(new Date().getFullYear(), 0, 1));
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
});
afterEach(() => {
clock.restore();
});
it('default properties', async () => {
const el = await fixture<WaFormatDate>(html` <wa-format-date></wa-format-date> `);
expect(el.date).to.deep.equal(new Date());
expect(el.lang).to.be.undefined;
expect(el.weekday).to.be.undefined;
expect(el.era).to.be.undefined;
expect(el.year).to.be.undefined;
expect(el.month).to.be.undefined;
expect(el.day).to.be.undefined;
expect(el.hour).to.be.undefined;
expect(el.minute).to.be.undefined;
expect(el.second).to.be.undefined;
expect(el.timeZoneName).to.be.undefined;
expect(el.timeZone).to.be.undefined;
expect(el.hourFormat).to.equal('auto');
});
});
describe('lang property', () => {
const results = [
{ lang: 'de', result: `1.1.${new Date().getFullYear()}` },
{ lang: 'de-CH', result: `1.1.${new Date().getFullYear()}` },
{ lang: 'fr', result: `01/01/${new Date().getFullYear()}` },
{ lang: 'es', result: `1/1/${new Date().getFullYear()}` },
{ lang: 'he', result: `1.1.${new Date().getFullYear()}` },
{ lang: 'ja', result: `${new Date().getFullYear()}/1/1` },
{ lang: 'nl', result: `1-1-${new Date().getFullYear()}` },
{ lang: 'pl', result: `1.01.${new Date().getFullYear()}` },
{ lang: 'pt', result: `01/01/${new Date().getFullYear()}` },
{ lang: 'ru', result: `01.01.${new Date().getFullYear()}` }
];
results.forEach(setup => {
it(`date has correct language format: ${setup.lang}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" lang="${setup.lang}"></wa-format-date>
`);
expect(el.shadowRoot?.textContent?.trim()).to.equal(setup.result);
});
});
});
describe('weekday property', () => {
const weekdays = ['narrow', 'short', 'long'];
weekdays.forEach((weekdayFormat: 'narrow' | 'short' | 'long') => {
it(`date has correct weekday format: ${weekdayFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
weekday="${weekdayFormat}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { weekday: weekdayFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('era property', () => {
const eras = ['narrow', 'short', 'long'];
eras.forEach((eraFormat: 'narrow' | 'short' | 'long') => {
it(`date has correct era format: ${eraFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" era="${eraFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { era: eraFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('year property', () => {
const yearFormats = ['numeric', '2-digit'];
yearFormats.forEach((yearFormat: 'numeric' | '2-digit') => {
it(`date has correct year format: ${yearFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" year="${yearFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { year: yearFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('month property', () => {
const monthFormats = ['numeric', '2-digit', 'narrow', 'short', 'long'];
monthFormats.forEach((monthFormat: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long') => {
it(`date has correct month format: ${monthFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" month="${monthFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { month: monthFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('day property', () => {
const dayFormats = ['numeric', '2-digit'];
dayFormats.forEach((dayFormat: 'numeric' | '2-digit') => {
it(`date has correct day format: ${dayFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" day="${dayFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { day: dayFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('hour property', () => {
const hourFormats = ['numeric', '2-digit'];
hourFormats.forEach((hourFormat: 'numeric' | '2-digit') => {
it(`date has correct hour format: ${hourFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" hour="${hourFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { hour: hourFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('minute property', () => {
const minuteFormats = ['numeric', '2-digit'];
minuteFormats.forEach((minuteFormat: 'numeric' | '2-digit') => {
it(`date has correct minute format: ${minuteFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" minute="${minuteFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { minute: minuteFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('second property', () => {
const secondFormats = ['numeric', '2-digit'];
secondFormats.forEach((secondFormat: 'numeric' | '2-digit') => {
it(`date has correct second format: ${secondFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" second="${secondFormat}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { second: secondFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('timeZoneName property', () => {
const timeZoneNameFormats = ['short', 'long'];
timeZoneNameFormats.forEach((timeZoneNameFormat: 'short' | 'long') => {
it(`date has correct timeZoneName format: ${timeZoneNameFormat}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
time-zone-name="${timeZoneNameFormat}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { timeZoneName: timeZoneNameFormat }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('timeZone property', () => {
const timeZones = ['America/New_York', 'America/Los_Angeles', 'Europe/Zurich'];
timeZones.forEach(timeZone => {
it(`date has correct timeZoneName format: ${timeZone}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date .date="${new Date(new Date().getFullYear(), 0, 1)}" time-zone="${timeZone}"></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', { timeZone: timeZone }).format(
new Date(new Date().getFullYear(), 0, 1)
);
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
describe('hourFormat property', () => {
const hourFormatValues = ['auto', '12', '24'];
hourFormatValues.forEach(hourFormatValue => {
it(`date has correct hourFormat format: ${hourFormatValue}`, async () => {
const el = await fixture<WaFormatDate>(html`
<wa-format-date
.date="${new Date(new Date().getFullYear(), 0, 1)}"
hour-format="${hourFormatValue as 'auto' | '12' | '24'}"
></wa-format-date>
`);
const expected = new Intl.DateTimeFormat('en-US', {
hour12: hourFormatValue === 'auto' ? undefined : hourFormatValue === '12'
}).format(new Date(new Date().getFullYear(), 0, 1));
expect(el.shadowRoot?.textContent?.trim()).to.equal(expected);
});
});
});
}
});

View File

@@ -1,166 +1,175 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaFormatNumber from './format-number.js';
describe('<wa-format-number>', () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaFormatNumber>(html` <wa-format-number></wa-format-number> `);
expect(el.value).to.equal(0);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaFormatNumber>(html` <wa-format-number></wa-format-number> `);
expect(el.value).to.equal(0);
expect(el.lang).to.be.undefined;
expect(el.type).to.equal('decimal');
expect(el.noGrouping).to.be.false;
expect(el.currency).to.equal('USD');
expect(el.currencyDisplay).to.equal('symbol');
expect(el.minimumIntegerDigits).to.be.undefined;
expect(el.minimumFractionDigits).to.be.undefined;
expect(el.maximumFractionDigits).to.be.undefined;
expect(el.minimumSignificantDigits).to.be.undefined;
expect(el.maximumSignificantDigits).to.be.undefined;
});
});
expect(el.lang).to.be.undefined;
expect(el.type).to.equal('decimal');
expect(el.noGrouping).to.be.false;
expect(el.currency).to.equal('USD');
expect(el.currencyDisplay).to.equal('symbol');
expect(el.minimumIntegerDigits).to.be.undefined;
expect(el.minimumFractionDigits).to.be.undefined;
expect(el.maximumFractionDigits).to.be.undefined;
expect(el.minimumSignificantDigits).to.be.undefined;
expect(el.maximumSignificantDigits).to.be.undefined;
});
});
describe('lang property', () => {
['de', 'de-CH', 'fr', 'es', 'he', 'ja', 'nl', 'pl', 'pt', 'ru'].forEach(lang => {
it(`number has correct language format: ${lang}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" lang="${lang}"></wa-format-number>
`);
const expected = new Intl.NumberFormat(lang, { style: 'decimal', useGrouping: true }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
describe('lang property', () => {
['de', 'de-CH', 'fr', 'es', 'he', 'ja', 'nl', 'pl', 'pt', 'ru'].forEach(lang => {
it(`number has correct language format: ${lang}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" lang="${lang}"></wa-format-number>
`);
const expected = new Intl.NumberFormat(lang, { style: 'decimal', useGrouping: true }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('type property', () => {
['currency', 'decimal', 'percent'].forEach(type => {
it(`number has correct type format: ${type}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" type="${type}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', { style: type, currency: 'USD' }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('noGrouping property', () => {
it(`number has correct grouping format: no grouping`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" no-grouping></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', { useGrouping: false }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
it(`number has correct grouping format: grouping`, async () => {
const el = await fixture<WaFormatNumber>(html` <wa-format-number value="1000"></wa-format-number> `);
const expected = new Intl.NumberFormat('en-US', { useGrouping: true }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
describe('currency property', () => {
['USD', 'CAD', 'AUD', 'UAH'].forEach(currency => {
it(`number has correct type format: ${currency}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" currency="${currency}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', { style: 'decimal', currency: currency }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('currencyDisplay property', () => {
['symbol', 'narrowSymbol', 'code', 'name'].forEach(currencyDisplay => {
it(`number has correct type format: ${currencyDisplay}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" currency-display="${currencyDisplay}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: currencyDisplay
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('minimumIntegerDigits property', () => {
[4, 5, 6].forEach(minDigits => {
it(`number has correct type format: ${minDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" minimum-integer-digits="${minDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
minimumIntegerDigits: minDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('minimumFractionDigits property', () => {
[4, 5, 6].forEach(minFractionDigits => {
it(`number has correct type format: ${minFractionDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" minimum-fraction-digits="${minFractionDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
minimumFractionDigits: minFractionDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('maximumFractionDigits property', () => {
[4, 5, 6].forEach(maxFractionDigits => {
it(`number has correct type format: ${maxFractionDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" maximum-fraction-digits="${maxFractionDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
maximumFractionDigits: maxFractionDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('minimumSignificantDigits property', () => {
[4, 5, 6].forEach(minSignificantDigits => {
it(`number has correct type format: ${minSignificantDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" minimum-significant-digits="${minSignificantDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
minimumSignificantDigits: minSignificantDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('maximumSignificantDigits property', () => {
[4, 5, 6].forEach(maxSignificantDigits => {
it(`number has correct type format: ${maxSignificantDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" maximum-significant-digits="${maxSignificantDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
maximumSignificantDigits: maxSignificantDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
});
});
describe('type property', () => {
['currency', 'decimal', 'percent'].forEach(type => {
it(`number has correct type format: ${type}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" type="${type}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', { style: type, currency: 'USD' }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('noGrouping property', () => {
it(`number has correct grouping format: no grouping`, async () => {
const el = await fixture<WaFormatNumber>(html` <wa-format-number value="1000" no-grouping></wa-format-number> `);
const expected = new Intl.NumberFormat('en-US', { useGrouping: false }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
it(`number has correct grouping format: grouping`, async () => {
const el = await fixture<WaFormatNumber>(html` <wa-format-number value="1000"></wa-format-number> `);
const expected = new Intl.NumberFormat('en-US', { useGrouping: true }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
describe('currency property', () => {
['USD', 'CAD', 'AUD', 'UAH'].forEach(currency => {
it(`number has correct type format: ${currency}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" currency="${currency}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', { style: 'decimal', currency: currency }).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('currencyDisplay property', () => {
['symbol', 'narrowSymbol', 'code', 'name'].forEach(currencyDisplay => {
it(`number has correct type format: ${currencyDisplay}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" currency-display="${currencyDisplay}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', { style: 'decimal', currencyDisplay: currencyDisplay }).format(
1000
);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('minimumIntegerDigits property', () => {
[4, 5, 6].forEach(minDigits => {
it(`number has correct type format: ${minDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" minimum-integer-digits="${minDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
minimumIntegerDigits: minDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('minimumFractionDigits property', () => {
[4, 5, 6].forEach(minFractionDigits => {
it(`number has correct type format: ${minFractionDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" minimum-fraction-digits="${minFractionDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
minimumFractionDigits: minFractionDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('maximumFractionDigits property', () => {
[4, 5, 6].forEach(maxFractionDigits => {
it(`number has correct type format: ${maxFractionDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" maximum-fraction-digits="${maxFractionDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
maximumFractionDigits: maxFractionDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('minimumSignificantDigits property', () => {
[4, 5, 6].forEach(minSignificantDigits => {
it(`number has correct type format: ${minSignificantDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" minimum-significant-digits="${minSignificantDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
minimumSignificantDigits: minSignificantDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
describe('maximumSignificantDigits property', () => {
[4, 5, 6].forEach(maxSignificantDigits => {
it(`number has correct type format: ${maxSignificantDigits}`, async () => {
const el = await fixture<WaFormatNumber>(html`
<wa-format-number value="1000" maximum-significant-digits="${maxSignificantDigits}"></wa-format-number>
`);
const expected = new Intl.NumberFormat('en-US', {
style: 'decimal',
currencyDisplay: 'symbol',
maximumSignificantDigits: maxSignificantDigits
}).format(1000);
expect(el.shadowRoot?.textContent).to.equal(expected);
});
});
});
}
});

View File

@@ -1,170 +1,180 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { expect, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import sinon from 'sinon';
import type WaIconButton from './icon-button.js';
type LinkTarget = '_self' | '_blank' | '_parent' | '_top';
describe('<wa-icon-button>', () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
expect(el.name).to.be.null;
expect(el.library).to.be.undefined;
expect(el.src).to.be.undefined;
expect(el.href).to.be.undefined;
expect(el.target).to.be.undefined;
expect(el.download).to.be.undefined;
expect(el.label).to.equal('');
expect(el.disabled).to.equal(false);
});
it('renders as a button by default', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
expect(el.shadowRoot?.querySelector('button')).to.exist;
expect(el.shadowRoot?.querySelector('a')).not.to.exist;
});
});
describe('when styling the host element', () => {
it('renders the correct color and font size', async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button
library="system"
name="check"
style="color: rgb(0, 136, 221); font-size: 2rem;"
></wa-icon-button>
`);
const icon = el.shadowRoot!.querySelector('wa-icon')!;
const styles = getComputedStyle(icon);
expect(styles.color).to.equal('rgb(0, 136, 221)');
expect(styles.fontSize).to.equal('32px');
});
});
describe('when icon attributes are present', () => {
it('renders an wa-icon from a library', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button library="system" name="check"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector('wa-icon')).to.exist;
});
it('renders an wa-icon from a src', async () => {
const fakeId = 'test-src';
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
el.src = `data:image/svg+xml,${encodeURIComponent(`<svg id="${fakeId}"></svg>`)}`;
const internalWaIcon = el.shadowRoot?.querySelector('wa-icon');
await waitUntil(() => internalWaIcon?.shadowRoot?.querySelector('svg'), 'SVG not rendered');
expect(internalWaIcon).to.exist;
expect(internalWaIcon?.shadowRoot?.querySelector('svg')).to.exist;
expect(internalWaIcon?.shadowRoot?.querySelector('svg')?.getAttribute('id')).to.equal(fakeId);
});
});
describe('when href is present', () => {
it('renders as an anchor', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector('a')).to.exist;
expect(el.shadowRoot?.querySelector('button')).not.to.exist;
});
it(`the anchor rel is not present`, async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`a[rel]`)).not.to.exist;
});
describe('and target is present', () => {
['_blank', '_parent', '_self', '_top'].forEach((target: LinkTarget) => {
it(`the anchor target is the provided target: ${target}`, async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" target="${target}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[target="${target}"]`)).to.exist;
expect(el.name).to.be.null;
expect(el.library).to.be.undefined;
expect(el.src).to.be.undefined;
expect(el.href).to.be.undefined;
expect(el.target).to.be.undefined;
expect(el.download).to.be.undefined;
expect(el.label).to.equal('');
expect(el.disabled).to.equal(false);
});
it(`the anchor rel is set to 'noreferrer noopener'`, async () => {
it('renders as a button by default', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
expect(el.shadowRoot?.querySelector('button')).to.exist;
expect(el.shadowRoot?.querySelector('a')).not.to.exist;
});
});
describe('when styling the host element', () => {
it('renders the correct color and font size', async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" target="${target}"></wa-icon-button>
<wa-icon-button
library="system"
name="check"
style="color: rgb(0, 136, 221); font-size: 2rem;"
></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[rel="noreferrer noopener"]`)).to.exist;
const icon = el.shadowRoot!.querySelector('wa-icon')!;
const styles = getComputedStyle(icon);
expect(styles.color).to.equal('rgb(0, 136, 221)');
expect(styles.fontSize).to.equal('32px');
});
});
describe('when icon attributes are present', () => {
it('renders an wa-icon from a library', async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button library="system" name="check"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector('wa-icon')).to.exist;
});
it('renders an wa-icon from a src', async () => {
const fakeId = 'test-src';
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
el.src = `data:image/svg+xml,${encodeURIComponent(`<svg id="${fakeId}"></svg>`)}`;
await el.updateComplete;
const internalWaIcon = el.shadowRoot?.querySelector('wa-icon');
await waitUntil(() => internalWaIcon?.shadowRoot?.querySelector('svg'), 'SVG not rendered');
expect(internalWaIcon).to.exist;
expect(internalWaIcon?.shadowRoot?.querySelector('svg')).to.exist;
expect(internalWaIcon?.shadowRoot?.querySelector('svg')?.getAttribute('id')).to.equal(fakeId);
});
});
describe('when href is present', () => {
it('renders as an anchor', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector('a')).to.exist;
expect(el.shadowRoot?.querySelector('button')).not.to.exist;
});
it(`the anchor rel is not present`, async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`a[rel]`)).not.to.exist;
});
describe('and target is present', () => {
['_blank', '_parent', '_self', '_top'].forEach((target: LinkTarget) => {
it(`the anchor target is the provided target: ${target}`, async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" target="${target}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[target="${target}"]`)).to.exist;
});
it(`the anchor rel is set to 'noreferrer noopener'`, async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" target="${target}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[rel="noreferrer noopener"]`)).to.exist;
});
});
});
describe('and download is present', () => {
it(`the anchor download attribute is the provided download`, async () => {
const fakeDownload = 'some/path';
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" download="${fakeDownload}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[download="${fakeDownload}"]`)).to.exist;
});
});
});
describe('when label is present', () => {
it('the internal aria-label attribute is set to the provided label when rendering a button', async () => {
const fakeLabel = 'some label';
const el = await fixture<WaIconButton>(html` <wa-icon-button label="${fakeLabel}"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`button[aria-label="${fakeLabel}"]`)).to.exist;
});
it('the internal aria-label attribute is set to the provided label when rendering an anchor', async () => {
const fakeLabel = 'some label';
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" label="${fakeLabel}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[aria-label="${fakeLabel}"]`)).to.exist;
});
});
describe('when disabled is present', () => {
it('the internal button has a disabled attribute when rendering a button', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button disabled></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`button[disabled]`)).to.exist;
});
it('the internal anchor has an aria-disabled attribute when rendering an anchor', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path" disabled></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`a[aria-disabled="true"]`)).to.exist;
});
});
describe('when using methods', () => {
it('should emit wa-focus and wa-blur when the button is focused and blurred', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
el.addEventListener('wa-focus', focusHandler);
el.addEventListener('wa-blur', blurHandler);
el.focus();
await waitUntil(() => focusHandler.calledOnce);
el.blur();
await waitUntil(() => blurHandler.calledOnce);
expect(focusHandler).to.have.been.calledOnce;
expect(blurHandler).to.have.been.calledOnce;
});
it('should emit a click event when calling click()', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
const clickHandler = sinon.spy();
el.addEventListener('click', clickHandler);
el.click();
await waitUntil(() => clickHandler.calledOnce);
expect(clickHandler).to.have.been.calledOnce;
});
});
});
describe('and download is present', () => {
it(`the anchor download attribute is the provided download`, async () => {
const fakeDownload = 'some/path';
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" download="${fakeDownload}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[download="${fakeDownload}"]`)).to.exist;
});
});
});
describe('when label is present', () => {
it('the internal aria-label attribute is set to the provided label when rendering a button', async () => {
const fakeLabel = 'some label';
const el = await fixture<WaIconButton>(html` <wa-icon-button label="${fakeLabel}"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`button[aria-label="${fakeLabel}"]`)).to.exist;
});
it('the internal aria-label attribute is set to the provided label when rendering an anchor', async () => {
const fakeLabel = 'some label';
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" label="${fakeLabel}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[aria-label="${fakeLabel}"]`)).to.exist;
});
});
describe('when disabled is present', () => {
it('the internal button has a disabled attribute when rendering a button', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button disabled></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`button[disabled]`)).to.exist;
});
it('the internal anchor has an aria-disabled attribute when rendering an anchor', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path" disabled></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`a[aria-disabled="true"]`)).to.exist;
});
});
describe('when using methods', () => {
it('should emit wa-focus and wa-blur when the button is focused and blurred', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
el.addEventListener('wa-focus', focusHandler);
el.addEventListener('wa-blur', blurHandler);
el.focus();
await waitUntil(() => focusHandler.calledOnce);
el.blur();
await waitUntil(() => blurHandler.calledOnce);
expect(focusHandler).to.have.been.calledOnce;
expect(blurHandler).to.have.been.calledOnce;
});
it('should emit a click event when calling click()', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
const clickHandler = sinon.spy();
el.addEventListener('click', clickHandler);
el.click();
await waitUntil(() => clickHandler.calledOnce);
expect(clickHandler).to.have.been.calledOnce;
});
});
}
});

View File

@@ -1,5 +1,9 @@
import { aTimeout, elementUpdated, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { registerIconLibrary } from '../../../dist/webawesome.js';
import { aTimeout, elementUpdated, expect, oneEvent } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
// Make sure this is `dist-cdn/` otherwise you will get an error.
import { registerIconLibrary } from '../../../dist-cdn/webawesome.js';
import type { WaErrorEvent } from '../../events/error.js';
import type { WaLoadEvent } from '../../events/load.js';
import type WaIcon from './icon.js';
@@ -36,202 +40,214 @@ describe('<wa-icon>', () => {
});
});
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaIcon>(html` <wa-icon></wa-icon> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaIcon>(html` <wa-icon></wa-icon> `);
expect(el.name).to.be.undefined;
expect(el.src).to.be.undefined;
expect(el.label).to.equal('');
expect(el.library).to.equal('default');
});
expect(el.name).to.be.undefined;
expect(el.src).to.be.undefined;
expect(el.label).to.equal('');
expect(el.library).to.equal('default');
});
it('renders pre-loaded system icons and emits wa-load event', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="system"></wa-icon> `);
const listener = oneEvent(el, 'wa-load') as Promise<WaLoadEvent>;
it('renders pre-loaded system icons and emits wa-load event', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="system"></wa-icon> `);
const listener = oneEvent(el, 'wa-load') as Promise<WaLoadEvent>;
el.name = 'check';
const ev = await listener;
await elementUpdated(el);
el.name = 'check';
const ev = await listener;
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.exist;
expect(ev).to.exist;
});
expect(el.shadowRoot?.querySelector('svg')).to.exist;
expect(ev).to.exist;
});
it('the icon is accessible', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="system" name="check"></wa-icon> `);
await expect(el).to.be.accessible();
});
it('the icon is accessible', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="system" name="check"></wa-icon> `);
await expect(el).to.be.accessible();
});
it('the icon has the correct default aria attributes', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="system" name="check"></wa-icon> `);
it('the icon has the correct default aria attributes', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="system" name="check"></wa-icon> `);
expect(el.getAttribute('role')).to.be.null;
expect(el.getAttribute('aria-label')).to.be.null;
expect(el.getAttribute('aria-hidden')).to.equal('true');
});
});
describe('when a label is provided', () => {
it('the icon has the correct default aria attributes', async () => {
const fakeLabel = 'a label';
const el = await fixture<WaIcon>(html` <wa-icon label="${fakeLabel}" library="system" name="check"></wa-icon> `);
expect(el.getAttribute('role')).to.equal('img');
expect(el.getAttribute('aria-label')).to.equal(fakeLabel);
expect(el.getAttribute('aria-hidden')).to.be.null;
});
});
describe('when a valid src is provided', () => {
it('the svg is rendered', async () => {
const fakeId = 'test-src';
const el = await fixture<WaIcon>(html` <wa-icon></wa-icon> `);
const listener = oneEvent(el, 'wa-load');
el.src = `data:image/svg+xml,${encodeURIComponent(`<svg id="${fakeId}"></svg>`)}`;
await listener;
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.exist;
expect(el.shadowRoot?.querySelector('svg')?.part.contains('svg')).to.be.true;
expect(el.shadowRoot?.querySelector('svg')?.getAttribute('id')).to.equal(fakeId);
});
});
describe('new library', () => {
it('renders icons from the new library and emits wa-load event', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library"></wa-icon> `);
const listener = oneEvent(el, 'wa-load') as Promise<WaLoadEvent>;
el.name = 'test-icon1';
const ev = await listener;
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.exist;
expect(ev.isTrusted).to.exist;
});
it('runs mutator from new library', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library" name="test-icon1"></wa-icon> `);
await elementUpdated(el);
const svg = el.shadowRoot?.querySelector('svg');
expect(svg?.getAttribute('fill')).to.equal('currentColor');
});
});
describe('negative cases', () => {
// using new library so we can test for malformed icons when registered
it("svg not rendered with an icon that doesn't exist in the library", async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library" name="does-not-exist"></wa-icon> `);
expect(el.shadowRoot?.querySelector('svg')).to.be.null;
});
it('emits wa-error when the file cant be retrieved', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library"></wa-icon> `);
const listener = oneEvent(el, 'wa-error') as Promise<WaErrorEvent>;
el.name = 'bad-request';
const ev = await listener;
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.be.null;
expect(ev).to.exist;
});
it("emits wa-error when there isn't an svg element in the registered icon", async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library"></wa-icon> `);
const listener = oneEvent(el, 'wa-error') as Promise<WaErrorEvent>;
el.name = 'bad-icon';
const ev = await listener;
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.be.null;
expect(ev).to.exist;
});
});
describe('svg sprite sheets', () => {
// For some reason ESLint wants to fail in CI here, but works locally.
// TODO: this test is skipped because Bootstrap sprite.svg doesn't seem to be available in CI. Will fix in a future PR. [Konnor]
/* eslint-disable */
it.skip('Should properly grab an SVG and render it from bootstrap icons', async () => {
registerIconLibrary('sprite', {
resolver: name => `/docs/assets/images/sprite.svg#${name}`,
mutator: svg => svg.setAttribute('fill', 'currentColor'),
spriteSheet: true
expect(el.getAttribute('role')).to.be.null;
expect(el.getAttribute('aria-label')).to.be.null;
expect(el.getAttribute('aria-hidden')).to.equal('true');
});
});
const el = await fixture<WaIcon>(html`<wa-icon name="arrow-left" library="sprite"></wa-icon>`);
describe('when a label is provided', () => {
it('the icon has the correct default aria attributes', async () => {
const fakeLabel = 'a label';
const el = await fixture<WaIcon>(html`
<wa-icon label="${fakeLabel}" library="system" name="check"></wa-icon>
`);
await elementUpdated(el);
const svg = el.shadowRoot?.querySelector("svg[part='svg']");
const use = svg?.querySelector(`use[href='/docs/assets/images/sprite.svg#arrow-left']`);
expect(svg).to.be.instanceof(SVGElement);
expect(use).to.be.instanceof(SVGUseElement);
// This is kind of hacky...but with no way to check "load", we just use a timeout
await aTimeout(1000);
// Theres no way to really test that the icon rendered properly. We just gotta trust the browser to do it's thing :)
// However, we can check the <use> size. It should be greater than 0x0 if loaded properly.
const rect = use?.getBoundingClientRect();
expect(rect?.width).to.be.greaterThan(0);
expect(rect?.width).to.be.greaterThan(0);
});
it('Should render nothing if the sprite hash is wrong', async () => {
registerIconLibrary('sprite', {
resolver: name => `/docs/assets/images/sprite.svg#${name}`,
mutator: svg => svg.setAttribute('fill', 'currentColor'),
spriteSheet: true
expect(el.getAttribute('role')).to.equal('img');
expect(el.getAttribute('aria-label')).to.equal(fakeLabel);
expect(el.getAttribute('aria-hidden')).to.be.null;
});
});
const el = await fixture<WaIcon>(html`<wa-icon name="non-existent" library="sprite"></wa-icon>`);
describe('when a valid src is provided', () => {
it('the svg is rendered', async () => {
const fakeId = 'test-src';
const el = await fixture<WaIcon>(html` <wa-icon></wa-icon> `);
await elementUpdated(el);
const listener = oneEvent(el, 'wa-load');
el.src = `data:image/svg+xml,${encodeURIComponent(`<svg id="${fakeId}"></svg>`)}`;
const svg = el.shadowRoot?.querySelector("svg[part='svg']");
const use = svg?.querySelector('use');
await listener;
await elementUpdated(el);
// TODO: Theres no way to really test that the icon rendered properly. We just gotta trust the browser to do it's thing :)
// However, we can check the <use> size. If it never loaded, it should be 0x0. Ideally, we could have error tracking...
const rect = use?.getBoundingClientRect();
expect(rect?.width).to.equal(0);
expect(rect?.width).to.equal(0);
// Make sure the mutator is applied.
// https://github.com/shoelace-style/shoelace/issues/1925
expect(svg?.getAttribute('fill')).to.equal('currentColor');
});
// TODO: <use> svg icons don't emit a "load" or "error" event...if we can figure out how to get the event to emit errors.
// Once we figure out how to emit errors / loading perhaps we can actually test this?
it.skip("Should produce an error if the icon doesn't exist.", async () => {
registerIconLibrary('sprite', {
resolver(name) {
return `/docs/assets/images/sprite.svg#${name}`;
},
mutator(svg) {
return svg.setAttribute('fill', 'currentColor');
},
spriteSheet: true
expect(el.shadowRoot?.querySelector('svg')).to.exist;
expect(el.shadowRoot?.querySelector('svg')?.part.contains('svg')).to.be.true;
expect(el.shadowRoot?.querySelector('svg')?.getAttribute('id')).to.equal(fakeId);
});
});
const el = await fixture<WaIcon>(html`<wa-icon name="bad-icon" library="sprite"></wa-icon>`);
const listener = oneEvent(el, 'wa-error') as Promise<WaErrorEvent>;
describe('new library', () => {
it('renders icons from the new library and emits wa-load event', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library"></wa-icon> `);
const listener = oneEvent(el, 'wa-load') as Promise<WaLoadEvent>;
el.name = 'bad-icon';
const ev = await listener;
await elementUpdated(el);
expect(ev).to.exist;
el.name = 'test-icon1';
const ev = await listener;
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.exist;
expect(ev.isTrusted).to.exist;
});
it('runs mutator from new library', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library" name="test-icon1"></wa-icon> `);
await elementUpdated(el);
await elementUpdated(el);
await aTimeout(1);
const svg = el.shadowRoot?.querySelector('svg');
expect(svg?.getAttribute('fill')).to.equal('currentColor');
});
});
describe('negative cases', () => {
// using new library so we can test for malformed icons when registered
it("svg not rendered with an icon that doesn't exist in the library", async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library" name="does-not-exist"></wa-icon> `);
// Still renders svgs for empty icons.
expect(el.shadowRoot?.querySelector('svg')).to.be.instanceof(SVGElement);
expect(el.getBoundingClientRect().height).to.equal(16);
expect(el.getBoundingClientRect().width).to.equal(16);
});
it('emits wa-error when the file cant be retrieved', async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library"></wa-icon> `);
const listener = oneEvent(el, 'wa-error') as Promise<WaErrorEvent>;
el.name = 'bad-request';
const ev = await listener;
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.be.null;
expect(ev).to.exist;
});
it("emits wa-error when there isn't an svg element in the registered icon", async () => {
const el = await fixture<WaIcon>(html` <wa-icon library="test-library"></wa-icon> `);
const listener = oneEvent(el, 'wa-error') as Promise<WaErrorEvent>;
el.name = 'bad-icon';
const ev = await listener;
await elementUpdated(el);
expect(el.shadowRoot?.querySelector('svg')).to.be.null;
expect(ev).to.exist;
});
});
describe('svg sprite sheets', () => {
// For some reason ESLint wants to fail in CI here, but works locally.
// TODO: this test is skipped because Bootstrap sprite.svg doesn't seem to be available in CI. Will fix in a future PR. [Konnor]
/* eslint-disable */
it.skip('Should properly grab an SVG and render it from bootstrap icons', async () => {
registerIconLibrary('sprite', {
resolver: name => `/docs/assets/images/sprite.svg#${name}`,
mutator: svg => svg.setAttribute('fill', 'currentColor'),
spriteSheet: true
});
const el = await fixture<WaIcon>(html`<wa-icon name="arrow-left" library="sprite"></wa-icon>`);
await elementUpdated(el);
const svg = el.shadowRoot?.querySelector("svg[part='svg']");
const use = svg?.querySelector(`use[href='/docs/assets/images/sprite.svg#arrow-left']`);
expect(svg).to.be.instanceof(SVGElement);
expect(use).to.be.instanceof(SVGUseElement);
// This is kind of hacky...but with no way to check "load", we just use a timeout
await aTimeout(1000);
// Theres no way to really test that the icon rendered properly. We just gotta trust the browser to do it's thing :)
// However, we can check the <use> size. It should be greater than 0x0 if loaded properly.
const rect = use?.getBoundingClientRect();
expect(rect?.width).to.be.greaterThan(0);
expect(rect?.width).to.be.greaterThan(0);
});
it('Should render nothing if the sprite hash is wrong', async () => {
registerIconLibrary('sprite', {
resolver: name => `/docs/assets/images/sprite.svg#${name}`,
mutator: svg => svg.setAttribute('fill', 'currentColor'),
spriteSheet: true
});
const el = await fixture<WaIcon>(html`<wa-icon name="non-existent" library="sprite"></wa-icon>`);
await elementUpdated(el);
const svg = el.shadowRoot?.querySelector("svg[part='svg']");
const use = svg?.querySelector('use');
// TODO: Theres no way to really test that the icon rendered properly. We just gotta trust the browser to do it's thing :)
// However, we can check the <use> size. If it never loaded, it should be 0x0. Ideally, we could have error tracking...
const rect = use?.getBoundingClientRect();
expect(rect?.width).to.equal(0);
expect(rect?.width).to.equal(0);
// Make sure the mutator is applied.
// https://github.com/shoelace-style/shoelace/issues/1925
expect(svg?.getAttribute('fill')).to.equal('currentColor');
});
// TODO: <use> svg icons don't emit a "load" or "error" event...if we can figure out how to get the event to emit errors.
// Once we figure out how to emit errors / loading perhaps we can actually test this?
it.skip("Should produce an error if the icon doesn't exist.", async () => {
registerIconLibrary('sprite', {
resolver(name) {
return `/docs/assets/images/sprite.svg#${name}`;
},
mutator(svg) {
return svg.setAttribute('fill', 'currentColor');
},
spriteSheet: true
});
const el = await fixture<WaIcon>(html`<wa-icon name="bad-icon" library="sprite"></wa-icon>`);
const listener = oneEvent(el, 'wa-error') as Promise<WaErrorEvent>;
el.name = 'bad-icon';
const ev = await listener;
await elementUpdated(el);
expect(ev).to.exist;
});
});
/* eslint-enable */
});
});
/* eslint-enable */
}
});

View File

@@ -9,7 +9,7 @@ import componentStyles from '../../styles/component.styles.js';
import styles from './icon.styles.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import type { CSSResultGroup, HTMLTemplateResult } from 'lit';
import type { CSSResultGroup, HTMLTemplateResult, PropertyValues } from 'lit';
const CACHEABLE_ERROR = Symbol();
const RETRYABLE_ERROR = Symbol();
@@ -227,8 +227,25 @@ export default class WaIcon extends WebAwesomeElement {
}
}
updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
// Sometimes (like with SSR -> hydration) mutators dont get applied due to race conditions. This ensures mutators get re-applied.
const library = getIconLibrary(this.library);
const svg = this.shadowRoot?.querySelector('svg');
if (svg) {
library?.mutator?.(svg);
}
}
render() {
return this.svg;
if (this.hasUpdated) {
return this.svg;
}
// @TODO: 16x16 is generally a safe bet. Perhaps be user setable?? `size="16x16"`, size="20x16". We just want to avoid "blowouts" with SSR.
return html`<svg part="svg" fill="currentColor" width="16" height="16"></svg>`;
}
}

View File

@@ -1,251 +1,257 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import sinon from 'sinon';
import type WaImageComparer from './image-comparer.js';
describe('<wa-image-comparer>', () => {
it('should render a basic before/after', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render a basic before/after', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const afterPart = el.shadowRoot!.querySelector<HTMLElement>('[part~="after"]')!;
const iconContainer = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name="handle"]')!;
const handle = el.shadowRoot!.querySelector<HTMLElement>('[part~="handle"]')!;
const afterPart = el.shadowRoot!.querySelector<HTMLElement>('[part~="after"]')!;
const iconContainer = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name="handle"]')!;
const handle = el.shadowRoot!.querySelector<HTMLElement>('[part~="handle"]')!;
expect(el.position).to.equal(50);
expect(afterPart.getAttribute('style')).to.equal('clip-path:inset(0 50% 0 0);');
expect(iconContainer.assignedElements().length).to.equal(0);
expect(handle.getAttribute('role')).to.equal('scrollbar');
expect(handle.getAttribute('aria-valuenow')).to.equal('50');
expect(handle.getAttribute('aria-valuemin')).to.equal('0');
expect(handle.getAttribute('aria-valuemax')).to.equal('100');
});
expect(el.position).to.equal(50);
expect(afterPart.getAttribute('style')).to.equal('clip-path:inset(0 50% 0 0);');
expect(iconContainer.assignedElements().length).to.equal(0);
expect(handle.getAttribute('role')).to.equal('scrollbar');
expect(handle.getAttribute('aria-valuenow')).to.equal('50');
expect(handle.getAttribute('aria-valuemin')).to.equal('0');
expect(handle.getAttribute('aria-valuemax')).to.equal('100');
});
it('should emit change event when position changed manually', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const handler = sinon.spy();
it('should emit change event when position changed manually', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const handler = sinon.spy();
el.addEventListener('wa-change', handler, { once: true });
el.addEventListener('wa-change', handler, { once: true });
el.position = 40;
await el.updateComplete;
el.position = 40;
await el.updateComplete;
expect(handler.called).to.equal(true);
});
expect(handler.called).to.equal(true);
});
it('should increment position on arrow right', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should increment position on arrow right', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight'
})
);
await el.updateComplete;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight'
})
);
await el.updateComplete;
expect(el.position).to.equal(51);
});
expect(el.position).to.equal(51);
});
it('should decrement position on arrow left', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should decrement position on arrow left', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft'
})
);
await el.updateComplete;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft'
})
);
await el.updateComplete;
expect(el.position).to.equal(49);
});
expect(el.position).to.equal(49);
});
it('should set position to 0 on home key', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should set position to 0 on home key', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Home'
})
);
await el.updateComplete;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Home'
})
);
await el.updateComplete;
expect(el.position).to.equal(0);
});
expect(el.position).to.equal(0);
});
it('should set position to 100 on end key', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should set position to 100 on end key', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'End'
})
);
await el.updateComplete;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'End'
})
);
await el.updateComplete;
expect(el.position).to.equal(100);
});
expect(el.position).to.equal(100);
});
it('should clamp to 100 on arrow right', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should clamp to 100 on arrow right', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
el.position = 0;
await el.updateComplete;
el.position = 0;
await el.updateComplete;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft'
})
);
await el.updateComplete;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft'
})
);
await el.updateComplete;
expect(el.position).to.equal(0);
});
expect(el.position).to.equal(0);
});
it('should clamp to 0 on arrow left', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should clamp to 0 on arrow left', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
el.position = 100;
await el.updateComplete;
el.position = 100;
await el.updateComplete;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight'
})
);
await el.updateComplete;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight'
})
);
await el.updateComplete;
expect(el.position).to.equal(100);
});
expect(el.position).to.equal(100);
});
it('should increment position by 10 on arrow right + shift', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should increment position by 10 on arrow right + shift', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight',
shiftKey: true
})
);
await el.updateComplete;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowRight',
shiftKey: true
})
);
await el.updateComplete;
expect(el.position).to.equal(60);
});
expect(el.position).to.equal(60);
});
it('should decrement position by 10 on arrow left + shift', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should decrement position by 10 on arrow left + shift', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft',
shiftKey: true
})
);
await el.updateComplete;
base.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowLeft',
shiftKey: true
})
);
await el.updateComplete;
expect(el.position).to.equal(40);
});
expect(el.position).to.equal(40);
});
it('should set position by attribute', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer position="10">
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
it('should set position by attribute', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer position="10">
<div slot="before"></div>
<div slot="after"></div>
</wa-image-comparer>
`);
expect(el.position).to.equal(10);
});
expect(el.position).to.equal(10);
});
it('should move position on drag', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before" style="width: 50px"></div>
<div slot="after" style="width: 50px"></div>
</wa-image-comparer>
`);
const handle = el.shadowRoot!.querySelector<HTMLElement>('[part~="handle"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const rect = base.getBoundingClientRect();
const offsetX = rect.left + window.pageXOffset;
const offsetY = rect.top + window.pageYOffset;
it('should move position on drag', async () => {
const el = await fixture<WaImageComparer>(html`
<wa-image-comparer>
<div slot="before" style="width: 50px"></div>
<div slot="after" style="width: 50px"></div>
</wa-image-comparer>
`);
const handle = el.shadowRoot!.querySelector<HTMLElement>('[part~="handle"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const rect = base.getBoundingClientRect();
const offsetX = rect.left + window.pageXOffset;
const offsetY = rect.top + window.pageYOffset;
handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
document.dispatchEvent(
new PointerEvent('pointermove', {
clientX: offsetX + 20,
clientY: offsetY
})
);
document.dispatchEvent(
new PointerEvent('pointermove', {
clientX: offsetX + 20,
clientY: offsetY
})
);
document.dispatchEvent(new PointerEvent('pointerup'));
document.dispatchEvent(new PointerEvent('pointerup'));
await el.updateComplete;
await el.updateComplete;
expect(el.position).to.equal(40);
});
expect(el.position).to.equal(40);
});
});
}
});

View File

@@ -96,7 +96,7 @@ export default class WaImageComparer extends WebAwesomeElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
return html`
<div

View File

@@ -1,4 +1,6 @@
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import sinon from 'sinon';
import type WaInclude from './include.js';
@@ -31,36 +33,43 @@ describe('<wa-include>', () => {
sinon.verifyAndRestore();
});
it('should load content and emit wa-load', async () => {
sinon.stub(window, 'fetch').resolves({
...stubbedFetchResponse,
ok: true,
status: 200,
text: () => delayResolve('"id": 1')
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should load content and emit wa-load', async () => {
sinon.stub(window, 'fetch').resolves({
...stubbedFetchResponse,
ok: true,
status: 200,
text: () => delayResolve('"id": 1')
});
const loadHandler = sinon.spy();
document.addEventListener('wa-load', loadHandler);
const el = await fixture<WaInclude>(html` <wa-include src="/found"></wa-include> `);
await waitUntil(() => loadHandler.calledOnce);
document.removeEventListener('wa-load', loadHandler);
expect(el.innerHTML).to.contain('"id": 1');
expect(loadHandler).to.have.been.calledOnce;
});
it('should emit wa-include-error when content cannot be loaded', async () => {
sinon.stub(window, 'fetch').resolves({
...stubbedFetchResponse,
ok: false,
status: 404,
text: () => delayResolve('{}')
});
const loadHandler = sinon.spy();
document.addEventListener('wa-include-error', loadHandler);
await fixture<WaInclude>(html` <wa-include src="/not-found"></wa-include> `);
await waitUntil(() => loadHandler.calledOnce);
document.removeEventListener('wa-include-error', loadHandler);
expect(loadHandler).to.have.been.calledOnce;
});
});
const el = await fixture<WaInclude>(html` <wa-include src="/found"></wa-include> `);
const loadHandler = sinon.spy();
el.addEventListener('wa-load', loadHandler);
await waitUntil(() => loadHandler.calledOnce);
expect(el.innerHTML).to.contain('"id": 1');
expect(loadHandler).to.have.been.calledOnce;
});
it('should emit wa-include-error when content cannot be loaded', async () => {
sinon.stub(window, 'fetch').resolves({
...stubbedFetchResponse,
ok: false,
status: 404,
text: () => delayResolve('{}')
});
const el = await fixture<WaInclude>(html` <wa-include src="/not-found"></wa-include> `);
const loadHandler = sinon.spy();
el.addEventListener('wa-include-error', loadHandler);
await waitUntil(() => loadHandler.calledOnce);
expect(loadHandler).to.have.been.calledOnce;
});
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import '../icon/icon.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { LocalizeController } from '../../utilities/localize.js';
@@ -96,14 +96,29 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
| 'time'
| 'url' = 'text';
/** The name of the input, submitted as a name/value pair with form data. */
@property({ reflect: true }) name: string | null = null;
private _value: string | null = null;
/** The current value of the input, submitted as a name/value pair with form data. */
@property({ attribute: false }) value = '';
get value() {
if (this.valueHasChanged) {
return this._value;
}
return this._value ?? this.defaultValue;
}
@state()
set value(val: string | null) {
if (this._value === val) {
return;
}
this.valueHasChanged = true;
this._value = val;
}
/** The default value of the form control. Primarily used for resetting the form control. */
@property({ attribute: 'value', reflect: true }) defaultValue = '';
@property({ attribute: 'value', reflect: true }) defaultValue: null | string = this.getAttribute('value') || null;
/** The input's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@@ -123,9 +138,6 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
/** Adds a clear button when the input is not empty. */
@property({ type: Boolean }) clearable = false;
/** Disables the input. */
@property({ type: Boolean }) disabled = false;
/** Placeholder text to show as a hint when the input is empty. */
@property() placeholder = '';
@@ -207,6 +219,16 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
*/
@property() inputmode: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
/**
* Used for SSR. Will determine if the SSRed component will have the label slot rendered on initial paint.
*/
@property({ attribute: 'with-label', type: Boolean }) withLabel = false;
/**
* Used for SSR. Will determine if the SSRed component will have the help text slot rendered on initial paint.
*/
@property({ attribute: 'with-help-text', type: Boolean }) withHelpText = false;
private handleBlur() {
this.hasFocus = false;
this.dispatchEvent(new WaBlurEvent());
@@ -371,12 +393,16 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabelSlot = this.hasUpdated ? this.hasSlotController.test('label') : this.withLabel;
const hasHelpTextSlot = this.hasUpdated ? this.hasSlotController.test('help-text') : this.withHelpText;
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
const hasClearIcon = this.clearable && !this.disabled && !this.readonly;
const isClearIconVisible = hasClearIcon && (typeof this.value === 'number' || this.value.length > 0);
const isClearIconVisible =
// prevents hydration mismatch errors.
(isServer || this.hasUpdated) &&
hasClearIcon &&
(typeof this.value === 'number' || (this.value && this.value.length > 0));
return html`
<div
@@ -440,7 +466,7 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step as number)}
.value=${live(this.value)}
.value=${live(this.value || '')}
autocapitalize=${ifDefined(this.autocapitalize)}
autocomplete=${ifDefined(this.autocomplete)}
autocorrect=${ifDefined(this.autocorrect)}

View File

@@ -1,182 +1,188 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import { expect, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type { WaSelectEvent } from '../../events/select.js';
import type WaMenuItem from './menu-item.js';
describe('<wa-menu-item>', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" checked>Checked</wa-menu-item>
<wa-menu-item type="checkbox">Unchecked</wa-menu-item>
</wa-menu>
`);
await expect(el).to.be.accessible();
});
it('should pass accessibility tests when using a submenu', async () => {
const el = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>
Submenu
<wa-menu slot="submenu">
<wa-menu-item>Submenu Item 1</wa-menu-item>
<wa-menu-item>Submenu Item 2</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
await expect(el).to.be.accessible();
});
it('should have the correct default properties', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Test</wa-menu-item> `);
expect(el.value).to.equal('');
expect(el.disabled).to.be.false;
expect(el.loading).to.equal(false);
expect(el.getAttribute('aria-disabled')).to.equal('false');
});
it('should render the correct aria attributes when disabled', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item disabled>Test</wa-menu-item> `);
expect(el.getAttribute('aria-disabled')).to.equal('true');
});
describe('when loading', () => {
it('should have a spinner present', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item loading>Menu Item Label</wa-menu-item> `);
expect(el.shadowRoot!.querySelector('wa-spinner')).to.exist;
});
});
it('should return a text label when calling getTextLabel()', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Test</wa-menu-item> `);
expect(el.getTextLabel()).to.equal('Test');
});
it('should emit the slotchange event when the label changes', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Text</wa-menu-item> `);
const slotChangeHandler = sinon.spy();
el.addEventListener('slotchange', slotChangeHandler);
el.textContent = 'New Text';
await waitUntil(() => slotChangeHandler.calledOnce);
expect(slotChangeHandler).to.have.been.calledOnce;
});
it('should render a hidden menu item when the inert attribute is used', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item inert>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
`);
const item1 = menu.querySelector('wa-menu-item')!;
expect(getComputedStyle(item1).display).to.equal('none');
});
it('should not render a wa-popup if the slot="submenu" attribute is missing, but the slot should exist in the component and be hidden.', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>
Item 1
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item> Nested Item 1 </wa-menu-item>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" checked>Checked</wa-menu-item>
<wa-menu-item type="checkbox">Unchecked</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
`);
await expect(el).to.be.accessible();
});
const menuItem: HTMLElement = menu.querySelector('wa-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('wa-popup')).to.be.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.true;
});
it('should render an wa-popup if the slot="submenu" attribute is present', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="test">
Item 1
<wa-menu slot="submenu">
<wa-menu-item> Nested Item 1 </wa-menu-item>
it('should pass accessibility tests when using a submenu', async () => {
const el = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>
Submenu
<wa-menu slot="submenu">
<wa-menu-item>Submenu Item 1</wa-menu-item>
<wa-menu-item>Submenu Item 2</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
`);
await expect(el).to.be.accessible();
});
const menuItem = menu.querySelector('wa-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('wa-popup')).to.be.not.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.false;
});
it('should have the correct default properties', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Test</wa-menu-item> `);
it('should focus on first menuitem of submenu if ArrowRight is pressed on parent menuitem', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="item-1">
Submenu
<wa-menu slot="submenu">
<wa-menu-item value="submenu-item-1"> Nested Item 1 </wa-menu-item>
expect(el.value).to.equal('');
expect(el.disabled).to.be.false;
expect(el.loading).to.equal(false);
expect(el.getAttribute('aria-disabled')).to.equal('false');
});
it('should render the correct aria attributes when disabled', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item disabled>Test</wa-menu-item> `);
expect(el.getAttribute('aria-disabled')).to.equal('true');
});
describe('when loading', () => {
it('should have a spinner present', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item loading>Menu Item Label</wa-menu-item> `);
expect(el.shadowRoot!.querySelector('wa-spinner')).to.exist;
});
});
it('should return a text label when calling getTextLabel()', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Test</wa-menu-item> `);
expect(el.getTextLabel()).to.equal('Test');
});
it('should emit the slotchange event when the label changes', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Text</wa-menu-item> `);
const slotChangeHandler = sinon.spy();
el.addEventListener('slotchange', slotChangeHandler);
el.textContent = 'New Text';
await waitUntil(() => slotChangeHandler.calledOnce);
expect(slotChangeHandler).to.have.been.calledOnce;
});
it('should render a hidden menu item when the inert attribute is used', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item inert>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
`);
const item1 = menu.querySelector('wa-menu-item')!;
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
expect(item.value).to.equal('submenu-item-1');
expect(getComputedStyle(item1).display).to.equal('none');
});
it('should not render a wa-popup if the slot="submenu" attribute is missing, but the slot should exist in the component and be hidden.', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>
Item 1
<wa-menu>
<wa-menu-item> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const menuItem: HTMLElement = menu.querySelector('wa-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('wa-popup')).to.be.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.true;
});
it('should render an wa-popup if the slot="submenu" attribute is present', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="test">
Item 1
<wa-menu slot="submenu">
<wa-menu-item> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const menuItem = menu.querySelector('wa-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('wa-popup')).to.be.not.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.false;
});
it('should focus on first menuitem of submenu if ArrowRight is pressed on parent menuitem', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="item-1">
Submenu
<wa-menu slot="submenu">
<wa-menu-item value="submenu-item-1"> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
expect(item.value).to.equal('submenu-item-1');
});
menu.addEventListener('wa-select', selectHandler);
const submenu = menu.querySelector<WaMenuItem>('wa-menu-item')!;
submenu.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
await menu.updateComplete;
await sendKeys({ press: 'Enter' });
await menu.updateComplete;
// Once for each menu element.
expect(selectHandler).to.have.been.calledTwice;
});
it('should focus on outer menu if ArrowRight is pressed on nested menuitem', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="outer" value="outer-item-1">
Submenu
<wa-menu slot="submenu">
<wa-menu-item value="inner-item-1"> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const focusHandler = sinon.spy((event: FocusEvent) => {
const target = event.target as WaMenuItem;
const relatedTarget = event.relatedTarget as WaMenuItem;
expect(target.value).to.equal('outer-item-1');
expect(relatedTarget.value).to.equal('inner-item-1');
});
const outerItem = menu.querySelector<WaMenuItem>('#outer')!;
outerItem.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
outerItem.addEventListener('focus', focusHandler);
await menu.updateComplete;
await sendKeys({ press: 'ArrowLeft' });
await menu.updateComplete;
expect(focusHandler).to.have.been.calledOnce;
});
});
menu.addEventListener('wa-select', selectHandler);
const submenu = menu.querySelector<WaMenuItem>('wa-menu-item')!;
submenu.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
await menu.updateComplete;
await sendKeys({ press: 'Enter' });
await menu.updateComplete;
// Once for each menu element.
expect(selectHandler).to.have.been.calledTwice;
});
it('should focus on outer menu if ArrowRight is pressed on nested menuitem', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="outer" value="outer-item-1">
Submenu
<wa-menu slot="submenu">
<wa-menu-item value="inner-item-1"> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const focusHandler = sinon.spy((event: FocusEvent) => {
const target = event.target as WaMenuItem;
const relatedTarget = event.relatedTarget as WaMenuItem;
expect(target.value).to.equal('outer-item-1');
expect(relatedTarget.value).to.equal('inner-item-1');
});
const outerItem = menu.querySelector<WaMenuItem>('#outer')!;
outerItem.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
outerItem.addEventListener('focus', focusHandler);
await menu.updateComplete;
await sendKeys({ press: 'ArrowLeft' });
await menu.updateComplete;
expect(focusHandler).to.have.been.calledOnce;
});
}
});

View File

@@ -10,7 +10,7 @@ import { watch } from '../../internal/watch.js';
import componentStyles from '../../styles/component.styles.js';
import styles from './menu-item.styles.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import type { CSSResultGroup } from 'lit';
import type { CSSResultGroup, PropertyValues } from 'lit';
/**
* @summary Menu items provide options for the user to pick from in a menu.
@@ -63,6 +63,11 @@ export default class WaMenuItem extends WebAwesomeElement {
/** Draws the menu item in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
/**
* Used for SSR purposes. If true, will render a ">" caret icon for showing that it has a submenu, but will be non-interactive.
*/
@property({ attribute: 'with-submenu', type: Boolean }) withSubmenu = false;
private submenuController: SubmenuController = new SubmenuController(this);
connectedCallback() {
@@ -77,6 +82,15 @@ export default class WaMenuItem extends WebAwesomeElement {
this.removeEventListener('mouseover', this.handleMouseOver);
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
// Kick it so that it renders the "submenu" properly.
if (this.isSubmenu()) {
this.requestUpdate();
}
super.firstUpdated(changedProperties);
}
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
@@ -145,11 +159,11 @@ export default class WaMenuItem extends WebAwesomeElement {
}
isSubmenu() {
return this.querySelector(`:scope > [slot="submenu"]`) !== null;
return this.hasUpdated ? this.querySelector(`:scope > [slot="submenu"]`) !== null : this.withSubmenu;
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
const isSubmenuExpanded = this.submenuController.isExpanded();
return html`

View File

@@ -195,7 +195,7 @@ export class SubmenuController implements ReactiveController {
private handlePopupReposition = () => {
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
const menu = submenuSlot?.assignedElements({ flatten: true }).filter(el => el.localName === 'wa-menu')[0];
const isRtl = this.host.matches(':dir(rtl)');
const isRtl = this.host.hasUpdated ? this.host.matches(':dir(rtl)') : this.host.dir === 'rtl';
if (!menu) {
return;
@@ -260,13 +260,13 @@ export class SubmenuController implements ReactiveController {
}
renderSubmenu() {
const isRtl = this.host.matches(':dir(rtl)');
// Always render the slot, but conditionally render the outer <wa-popup>
if (!this.isConnected) {
if (!this.host.hasUpdated) {
return html` <slot name="submenu" hidden></slot> `;
}
const isRtl = this.host.matches(':dir(rtl)');
return html`
<wa-popup
${ref(this.popupRef)}

View File

@@ -1,9 +1,15 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaMenuLabel from './menu-label.js';
describe('<wa-menu-label>', () => {
it('passes accessibility test', async () => {
const el = await fixture<WaMenuLabel>(html` <wa-menu-label>Test</wa-menu-label> `);
await expect(el).to.be.accessible();
});
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('passes accessibility test', async () => {
const el = await fixture<WaMenuLabel>(html` <wa-menu-label>Test</wa-menu-label> `);
await expect(el).to.be.accessible();
});
});
}
});

View File

@@ -1,5 +1,6 @@
import { clickOnElement } from '../../internal/test.js';
import { expect, fixture } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
@@ -7,116 +8,120 @@ import type { WaSelectEvent } from '../../events/select.js';
import type WaMenu from './menu.js';
describe('<wa-menu>', () => {
it('emits wa-select with the correct event detail when clicking an item', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2">Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const item2 = menu.querySelectorAll('wa-menu-item')[1];
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect event detail emitted with wa-select');
}
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('emits wa-select with the correct event detail when clicking an item', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2">Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const item2 = menu.querySelectorAll('wa-menu-item')[1];
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect event detail emitted with wa-select');
}
});
menu.addEventListener('wa-select', selectHandler);
await clickOnElement(item2);
expect(selectHandler).to.have.been.calledOnce;
});
it('can be selected via keyboard', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2">Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const [item1, item2] = menu.querySelectorAll('wa-menu-item');
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect item selected');
}
});
menu.addEventListener('wa-select', selectHandler);
item1.focus();
await item1.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await sendKeys({ press: 'Enter' });
expect(selectHandler).to.have.been.calledOnce;
});
it('does not select disabled items when clicking', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2" disabled>Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const item2 = menu.querySelectorAll('wa-menu-item')[1];
const selectHandler = sinon.spy();
menu.addEventListener('wa-select', selectHandler);
await clickOnElement(item2);
expect(selectHandler).to.not.have.been.calledOnce;
});
it('does not select disabled items when pressing enter', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2" disabled>Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const [item1, item2] = menu.querySelectorAll('wa-menu-item');
const selectHandler = sinon.spy();
menu.addEventListener('wa-select', selectHandler);
item1.focus();
await item1.updateComplete;
await sendKeys({ press: 'ArrowDown' });
expect(document.activeElement).to.equal(item2);
await sendKeys({ press: 'Enter' });
await item2.updateComplete;
expect(selectHandler).to.not.have.been.called;
});
// @see https://github.com/shoelace-style/shoelace/issues/1596
it('Should fire "wa-select" when clicking an element within a menu-item', async () => {
// eslint-disable-next-line
const selectHandler = sinon.spy(() => {});
const menu: WaMenu = await fixture(html`
<wa-menu>
<wa-menu-item>
<span>Menu item</span>
</wa-menu-item>
</wa-menu>
`);
menu.addEventListener('wa-select', selectHandler);
const span = menu.querySelector('span')!;
await clickOnElement(span);
expect(selectHandler).to.have.been.calledOnce;
});
});
menu.addEventListener('wa-select', selectHandler);
await clickOnElement(item2);
expect(selectHandler).to.have.been.calledOnce;
});
it('can be selected via keyboard', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2">Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const [item1, item2] = menu.querySelectorAll('wa-menu-item');
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect item selected');
}
});
menu.addEventListener('wa-select', selectHandler);
item1.focus();
await item1.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await sendKeys({ press: 'Enter' });
expect(selectHandler).to.have.been.calledOnce;
});
it('does not select disabled items when clicking', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2" disabled>Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const item2 = menu.querySelectorAll('wa-menu-item')[1];
const selectHandler = sinon.spy();
menu.addEventListener('wa-select', selectHandler);
await clickOnElement(item2);
expect(selectHandler).to.not.have.been.calledOnce;
});
it('does not select disabled items when pressing enter', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2" disabled>Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const [item1, item2] = menu.querySelectorAll('wa-menu-item');
const selectHandler = sinon.spy();
menu.addEventListener('wa-select', selectHandler);
item1.focus();
await item1.updateComplete;
await sendKeys({ press: 'ArrowDown' });
expect(document.activeElement).to.equal(item2);
await sendKeys({ press: 'Enter' });
await item2.updateComplete;
expect(selectHandler).to.not.have.been.called;
});
// @see https://github.com/shoelace-style/shoelace/issues/1596
it('Should fire "wa-select" when clicking an element within a menu-item', async () => {
// eslint-disable-next-line
const selectHandler = sinon.spy(() => {});
const menu: WaMenu = await fixture(html`
<wa-menu>
<wa-menu-item>
<span>Menu item</span>
</wa-menu-item>
</wa-menu>
`);
menu.addEventListener('wa-select', selectHandler);
const span = menu.querySelector('span')!;
await clickOnElement(span);
expect(selectHandler).to.have.been.calledOnce;
});
}
});

View File

@@ -1,9 +1,15 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
describe('<wa-mutation-observer>', () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-mutation-observer></wa-mutation-observer> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-mutation-observer></wa-mutation-observer> `);
expect(el).to.exist;
});
expect(el).to.exist;
});
});
}
});

View File

@@ -47,10 +47,12 @@ export default class WaMutationObserver extends WebAwesomeElement {
connectedCallback() {
super.connectedCallback();
this.mutationObserver = new MutationObserver(this.handleMutation);
if (typeof MutationObserver !== 'undefined') {
this.mutationObserver = new MutationObserver(this.handleMutation);
if (!this.disabled) {
this.startObserver();
if (!this.disabled) {
this.startObserver();
}
}
}

View File

@@ -1,59 +1,65 @@
import { aTimeout, expect, fixture, html, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import sinon from 'sinon';
import type WaOption from './option.js';
describe('<wa-option>', () => {
it('passes accessibility test', async () => {
const el = await fixture<WaOption>(html`
<wa-select label="Select one">
<wa-option value="1">Option 1</wa-option>
<wa-option value="2">Option 2</wa-option>
<wa-option value="3">Option 3</wa-option>
<wa-option value="4" disabled>Disabled</wa-option>
</wa-select>
`);
await expect(el).to.be.accessible();
});
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('passes accessibility test', async () => {
const el = await fixture<WaOption>(html`
<wa-select label="Select one">
<wa-option value="1">Option 1</wa-option>
<wa-option value="2">Option 2</wa-option>
<wa-option value="3">Option 3</wa-option>
<wa-option value="4" disabled>Disabled</wa-option>
</wa-select>
`);
await expect(el).to.be.accessible();
});
it('default properties', async () => {
const el = await fixture<WaOption>(html` <wa-option>Test</wa-option> `);
it('default properties', async () => {
const el = await fixture<WaOption>(html` <wa-option>Test</wa-option> `);
expect(el.value).to.equal('');
expect(el.disabled).to.be.false;
expect(el.getAttribute('aria-disabled')).to.equal('false');
});
expect(el.value).to.equal('');
expect(el.disabled).to.be.false;
expect(el.getAttribute('aria-disabled')).to.equal('false');
});
it('changes aria attributes', async () => {
const el = await fixture<WaOption>(html` <wa-option>Test</wa-option> `);
it('changes aria attributes', async () => {
const el = await fixture<WaOption>(html` <wa-option>Test</wa-option> `);
el.disabled = true;
await aTimeout(100);
expect(el.getAttribute('aria-disabled')).to.equal('true');
});
el.disabled = true;
await aTimeout(100);
expect(el.getAttribute('aria-disabled')).to.equal('true');
});
it('emits the slotchange event when the label changes', async () => {
const el = await fixture<WaOption>(html` <wa-option>Text</wa-option> `);
const slotChangeHandler = sinon.spy();
it('emits the slotchange event when the label changes', async () => {
const el = await fixture<WaOption>(html` <wa-option>Text</wa-option> `);
const slotChangeHandler = sinon.spy();
el.addEventListener('slotchange', slotChangeHandler);
el.textContent = 'New Text';
await waitUntil(() => slotChangeHandler.calledOnce);
el.addEventListener('slotchange', slotChangeHandler);
el.textContent = 'New Text';
await waitUntil(() => slotChangeHandler.calledOnce);
expect(slotChangeHandler).to.have.been.calledOnce;
});
expect(slotChangeHandler).to.have.been.calledOnce;
});
it('should convert non-string values to string', async () => {
const el = await fixture<WaOption>(html` <wa-option>Text</wa-option> `);
it('should convert non-string values to string', async () => {
const el = await fixture<WaOption>(html` <wa-option>Text</wa-option> `);
// @ts-expect-error - intentional
el.value = 10;
await el.updateComplete;
// @ts-expect-error - intentional
el.value = 10;
await el.updateComplete;
expect(el.value).to.equal('10');
});
expect(el.value).to.equal('10');
});
it('should escape HTML when calling getTextLabel()', async () => {
const el = await fixture<WaOption>(html` <wa-option><strong>Option</strong></wa-option> `);
expect(el.getTextLabel()).to.equal('Option');
});
it('should escape HTML when calling getTextLabel()', async () => {
const el = await fixture<WaOption>(html` <wa-option><strong>Option</strong></wa-option> `);
expect(el.getTextLabel()).to.equal('Option');
});
});
}
});

View File

@@ -1,8 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles.js';
export default css`
${componentStyles}
:host {
display: block;
box-sizing: border-box;
@@ -13,7 +11,9 @@ export default css`
--banner-height: 0px;
--header-height: 0px;
--subheader-height: 0px;
--scroll-margin-top: calc(var(--header-height, 0px) + var(--subheader-height, 0px));
}
:host([disable-sticky~='banner']) :is([part~='header'], [part~='subheader']) {
--banner-height: 0px !important;
}
@@ -41,10 +41,6 @@ export default css`
height: auto;
max-height: auto;
}
/* Hide nav toggles in desktop view */
:host([view='desktop']) ::slotted([data-toggle-nav]) {
display: none !important;
}
[part~='base'] {
min-height: 100%;
display: grid;
@@ -162,9 +158,7 @@ export default css`
max-height: calc(100dvh - var(--header-height) - var(--banner-height) - var(--subheader-height));
overflow: auto;
}
:host([view='mobile']) [part~='navigation'] {
display: none;
}
[part~='navigation'] {
height: 100%;
display: grid;
@@ -172,3 +166,11 @@ export default css`
grid-template-rows: minmax(0, auto) minmax(0, 1fr) minmax(0, auto);
}
`;
export const mobileStyles = (breakpoint: number) => `
@media screen and (
max-width: ${(Number.isSafeInteger(breakpoint) ? breakpoint.toString() : '768') + 'px'}
) {
[part~='navigation'] { display: none; }
}
`;

View File

@@ -1,9 +1,15 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
describe('<wa-page>', () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-page></wa-page> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-page></wa-page> `);
expect(el).to.exist;
});
expect(el).to.exist;
});
});
}
});

View File

@@ -1,12 +1,27 @@
import '../drawer/drawer.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { live } from 'lit/directives/live.js';
import styles from './page.styles.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import componentStyles from '../../styles/component.styles.js';
import styles, { mobileStyles } from './page.styles.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import type { CSSResultGroup, PropertyValueMap } from 'lit';
import type { CSSResultGroup, PropertyValues } from 'lit';
import type WaDrawer from '../drawer/drawer.js';
if (typeof ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
// eslint-disable-next-line
constructor(..._args: ConstructorParameters<typeof ResizeObserver>) {}
// eslint-disable-next-line
observe(..._args: Parameters<ResizeObserver['observe']>) {}
// eslint-disable-next-line
unobserve(..._args: Parameters<ResizeObserver['unobserve']>) {}
// eslint-disable-next-line
disconnect(..._args: Parameters<ResizeObserver['disconnect']>) {}
};
}
/**
* @summary Pages offer an easy way to scaffold pages using minimal markup.
* @documentation https://backers.webawesome.com/docs/components/page
@@ -53,7 +68,7 @@ import type WaDrawer from '../drawer/drawer.js';
*/
@customElement('wa-page')
export default class WaPage extends WebAwesomeElement {
static styles: CSSResultGroup = styles;
static styles: CSSResultGroup = [componentStyles, styles];
private headerResizeObserver = this.slotResizeObserver('header');
private subheaderResizeObserver = this.slotResizeObserver('subheader');
@@ -90,11 +105,12 @@ export default class WaPage extends WebAwesomeElement {
@query("[part~='drawer']") navigationDrawer: WaDrawer;
/**
* The view is a reflection of the "mobileBreakpoint", when the page is larger than the `mobile-breakpoint` (768 by
* The view is a reflection of the "mobileBreakpoint", when the page is larger than the `mobile-breakpoint` (768px by
* default), it is considered to be a "desktop" view. The view is merely a way to distinguish when to show/hide the
* navigation. You can use additional media queries to make other adjustments to content as necessary.
* The default is "desktop" because the "mobile navigation drawer" isn't accessible via SSR due to drawer requiring JS.
*/
@property({ attribute: 'view', reflect: true }) view: 'mobile' | 'desktop' = 'mobile';
@property({ attribute: 'view', reflect: true }) view: 'mobile' | 'desktop' = 'desktop';
/**
* Whether or not the navigation drawer is open. Note, the navigation drawer is only "open" on mobile views.
@@ -104,7 +120,7 @@ export default class WaPage extends WebAwesomeElement {
/**
* At what "px" to hide the "menu" slot and collapse into a hamburger button
*/
@property({ attribute: 'mobile-breakpoint' }) mobileBreakpoint = 768;
@property({ attribute: 'mobile-breakpoint', type: Number }) mobileBreakpoint = 768;
/**
* Where to place the navigation when in the mobile viewport.
@@ -132,7 +148,7 @@ export default class WaPage extends WebAwesomeElement {
}
});
protected update(changedProperties: PropertyValueMap<this> | Map<PropertyKey, unknown>): void {
protected update(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('view')) {
this.hideNavigation();
}
@@ -141,7 +157,10 @@ export default class WaPage extends WebAwesomeElement {
constructor() {
super();
this.addEventListener('click', this.handleNavigationToggle);
if (!isServer) {
this.addEventListener('click', this.handleNavigationToggle);
}
}
connectedCallback() {
@@ -203,6 +222,14 @@ export default class WaPage extends WebAwesomeElement {
<a href="#main-content" part="skip-to-content" class="skip-to-content">
<slot name="skip-to-content">Skip to content</slot>
</a>
<!-- unsafeHTML needed for SSR until this is solved: https://github.com/lit/lit/issues/4696 -->
${unsafeHTML(`
<style id="mobile-styles">
${mobileStyles(this.mobileBreakpoint)}
</style>
`)}
<div class="base" part="base">
<div class="banner" part="banner">
<slot name="banner"></slot>
@@ -218,9 +245,15 @@ export default class WaPage extends WebAwesomeElement {
<slot name="menu">
<nav name="navigation" class="navigation" part="navigation navigation-desktop">
<!-- Add fallback divs so that CSS grid works properly. -->
<slot name=${this.view === 'desktop' ? 'navigation-header' : '___'}><div></div></slot>
<slot name=${this.view === 'desktop' ? 'navigation' : '____'}></slot>
<slot name=${this.view === 'desktop' ? 'navigation-footer' : '___'}><div></div></slot>
<slot name="desktop-navigation-header">
<slot name=${this.view === 'desktop' ? 'navigation-header' : '___'}><div></div></slot>
</slot>
<slot name="desktop-navigation">
<slot name=${this.view === 'desktop' ? 'navigation' : '____'}><div></div></slot>
</slot>
<slot name="desktop-navigation-footer">
<slot name=${this.view === 'desktop' ? 'navigation-footer' : '___'}><div></div></slot>
</slot>
</nav>
</slot>
</div>
@@ -266,13 +299,20 @@ export default class WaPage extends WebAwesomeElement {
"
class="navigation-drawer"
>
<slot part="navigation-header" slot="label" name=${this.view === 'mobile' ? 'navigation-header' : '___'}></slot>
<slot name=${this.view === 'mobile' ? 'navigation' : '____'}></slot>
<slot
part="navigation-footer"
slot="footer"
name=${this.view === 'mobile' ? 'navigation-footer' : '___'}
></slot>
<slot slot="label" part="navigation-header" name="mobile-navigation-header">
<slot name=${this.view === 'mobile' ? 'navigation-header' : '___'}></slot>
</slot>
<slot name="mobile-navigation">
<slot name=${this.view === 'mobile' ? 'navigation' : '____'}></slot>
</slot>
<slot name="mobile-navigation-footer">
<slot
part="navigation-footer"
slot="footer"
name=${this.view === 'mobile' ? 'navigation-footer' : '___'}
></slot>
</slot>
</wa-drawer>
`;
}

View File

@@ -1,9 +1,15 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
describe('<wa-popup>', () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-popup></wa-popup> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-popup></wa-popup> `);
expect(el).to.exist;
});
expect(el).to.exist;
});
});
}
});

View File

@@ -1,85 +1,91 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaProgressBar from './progress-bar.js';
describe('<wa-progress-bar>', () => {
let el: WaProgressBar;
describe('when provided just a value parameter', () => {
before(async () => {
el = await fixture<WaProgressBar>(html`<wa-progress-bar value="25"></wa-progress-bar>`);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when provided just a value parameter', () => {
beforeEach(async () => {
el = await fixture<WaProgressBar>(html`<wa-progress-bar value="25"></wa-progress-bar>`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
describe('when provided a title, and value parameter', () => {
let base: HTMLDivElement;
let indicator: HTMLDivElement;
beforeEach(async () => {
el = await fixture<WaProgressBar>(
html`<wa-progress-bar title="Titled Progress Ring" value="25"></wa-progress-bar>`
);
base = el.shadowRoot!.querySelector('[part~="base"]')!;
indicator = el.shadowRoot!.querySelector('[part~="indicator"]')!;
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('uses the value parameter on the base, as aria-valuenow', () => {
expect(base).attribute('aria-valuenow', '25');
});
it('appends a % to the value, and uses it as the the value parameter to determine the width on the "indicator" part', () => {
expect(indicator).attribute('style', 'width:25%;');
});
});
describe('when provided an indeterminate parameter', () => {
let base: HTMLDivElement;
beforeEach(async () => {
el = await fixture<WaProgressBar>(
html`<wa-progress-bar title="Titled Progress Ring" indeterminate></wa-progress-bar>`
);
base = el.shadowRoot!.querySelector('[part~="base"]')!;
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should append a progress-bar--indeterminate class to the "base" part.', () => {
expect(base.classList.value.trim()).to.eq('progress-bar progress-bar--indeterminate');
});
});
describe('when provided a ariaLabel, and value parameter', () => {
beforeEach(async () => {
el = await fixture<WaProgressBar>(
html`<wa-progress-bar ariaLabel="Labelled Progress Ring" value="25"></wa-progress-bar>`
);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
describe('when provided a ariaLabelledBy, and value parameter', () => {
beforeEach(async () => {
el = await fixture<WaProgressBar>(html`
<label id="labelledby">Progress Ring Label</label>
<wa-progress-bar ariaLabelledBy="labelledby" value="25"></wa-progress-bar>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
describe('when provided a title, and value parameter', () => {
let base: HTMLDivElement;
let indicator: HTMLDivElement;
before(async () => {
el = await fixture<WaProgressBar>(
html`<wa-progress-bar title="Titled Progress Ring" value="25"></wa-progress-bar>`
);
base = el.shadowRoot!.querySelector('[part~="base"]')!;
indicator = el.shadowRoot!.querySelector('[part~="indicator"]')!;
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('uses the value parameter on the base, as aria-valuenow', () => {
expect(base).attribute('aria-valuenow', '25');
});
it('appends a % to the value, and uses it as the the value parameter to determine the width on the "indicator" part', () => {
expect(indicator).attribute('style', 'width:25%;');
});
});
describe('when provided an indeterminate parameter', () => {
let base: HTMLDivElement;
before(async () => {
el = await fixture<WaProgressBar>(
html`<wa-progress-bar title="Titled Progress Ring" indeterminate></wa-progress-bar>`
);
base = el.shadowRoot!.querySelector('[part~="base"]')!;
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should append a progress-bar--indeterminate class to the "base" part.', () => {
expect(base.classList.value.trim()).to.eq('progress-bar progress-bar--indeterminate');
});
});
describe('when provided a ariaLabel, and value parameter', () => {
before(async () => {
el = await fixture<WaProgressBar>(
html`<wa-progress-bar ariaLabel="Labelled Progress Ring" value="25"></wa-progress-bar>`
);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
describe('when provided a ariaLabelledBy, and value parameter', () => {
before(async () => {
el = await fixture<WaProgressBar>(html`
<label id="labelledby">Progress Ring Label</label>
<wa-progress-bar ariaLabelledBy="labelledby" value="25"></wa-progress-bar>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
}
});

View File

@@ -1,64 +1,70 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaProgressRing from './progress-ring.js';
describe('<wa-progress-ring>', () => {
let el: WaProgressRing;
describe('when provided just a value parameter', () => {
before(async () => {
el = await fixture<WaProgressRing>(html`<wa-progress-ring value="25"></wa-progress-ring>`);
});
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when provided just a value parameter', () => {
beforeEach(async () => {
el = await fixture<WaProgressRing>(html`<wa-progress-ring value="25"></wa-progress-ring>`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
describe('when provided a title, and value parameter', () => {
let base: HTMLDivElement;
describe('when provided a title, and value parameter', () => {
let base: HTMLDivElement;
before(async () => {
el = await fixture<WaProgressRing>(
html`<wa-progress-ring title="Titled Progress Ring" value="25"></wa-progress-ring>`
);
base = el.shadowRoot!.querySelector('[part~="base"]')!;
});
beforeEach(async () => {
el = await fixture<WaProgressRing>(
html`<wa-progress-ring title="Titled Progress Ring" value="25"></wa-progress-ring>`
);
base = el.shadowRoot!.querySelector('[part~="base"]')!;
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
it('uses the value parameter on the base, as aria-valuenow', () => {
expect(base).attribute('aria-valuenow', '25');
});
it('uses the value parameter on the base, as aria-valuenow', () => {
expect(base).attribute('aria-valuenow', '25');
});
it('translates the value parameter to a percentage, and uses translation on the base, as percentage css variable', () => {
expect(base).attribute('style', '--percentage: 0.25');
});
});
it('translates the value parameter to a percentage, and uses translation on the base, as percentage css variable', () => {
expect(base).attribute('style', '--percentage: 0.25');
});
});
describe('when provided a ariaLabel, and value parameter', () => {
before(async () => {
el = await fixture<WaProgressRing>(
html`<wa-progress-ring ariaLabel="Labelled Progress Ring" value="25"></wa-progress-ring>`
);
});
describe('when provided a ariaLabel, and value parameter', () => {
beforeEach(async () => {
el = await fixture<WaProgressRing>(
html`<wa-progress-ring ariaLabel="Labelled Progress Ring" value="25"></wa-progress-ring>`
);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
describe('when provided a ariaLabelledBy, and value parameter', () => {
before(async () => {
el = await fixture<WaProgressRing>(html`
<label id="labelledby">Progress Ring Label</label>
<wa-progress-ring ariaLabelledBy="labelledby" value="25"></wa-progress-ring>
`);
});
describe('when provided a ariaLabelledBy, and value parameter', () => {
beforeEach(async () => {
el = await fixture<WaProgressRing>(html`
<label id="labelledby">Progress Ring Label</label>
<wa-progress-ring ariaLabelledBy="labelledby" value="25"></wa-progress-ring>
`);
});
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
it('should pass accessibility tests', async () => {
await expect(el).to.be.accessible();
});
});
});
});
}
});

View File

@@ -2,6 +2,15 @@ import { css } from 'lit';
export default css`
:host {
--size: 128px;
display: inline-block;
}
:host,
canvas {
max-width: var(--size);
max-height: var(--size);
width: var(--size);
height: var(--size);
}
`;

View File

@@ -1,4 +1,6 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaQrCode from './qr-code.js';
const getCanvas = (qrCode: WaQrCode): HTMLCanvasElement => {
@@ -94,50 +96,54 @@ const expectQrCodeColorsToBe = (qrCode: WaQrCode, expectedColors: QrCodeColors):
};
describe('<wa-qr-code>', () => {
it('should render a component', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data"></wa-qr-code>`);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render a component', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data"></wa-qr-code>`);
expect(qrCode).to.exist;
});
expect(qrCode).to.exist;
});
it('should be accessible', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data"></wa-qr-code>`);
it('should be accessible', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data"></wa-qr-code>`);
await expect(qrCode).to.be.accessible();
});
await expect(qrCode).to.be.accessible();
});
it('uses the value as label if none given', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data"></wa-qr-code>`);
it('uses the value as label if none given', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data"></wa-qr-code>`);
expectCanvasToHaveAriaLabel(qrCode, 'test data');
});
expectCanvasToHaveAriaLabel(qrCode, 'test data');
});
it('uses the label if given', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data" label="test label"></wa-qr-code>`);
it('uses the label if given', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data" label="test label"></wa-qr-code>`);
expectCanvasToHaveAriaLabel(qrCode, 'test label');
});
expectCanvasToHaveAriaLabel(qrCode, 'test label');
});
it('sets the correct color for the qr code', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data" fill="red"></wa-qr-code>`);
it('sets the correct color for the qr code', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data" fill="red"></wa-qr-code>`);
expectQrCodeColorsToBe(qrCode, { foreground: red, background: white });
});
expectQrCodeColorsToBe(qrCode, { foreground: red, background: white });
});
it('sets the correct background for the qr code', async () => {
const qrCode = await fixture<WaQrCode>(
html` <wa-qr-code value="test data" fill="red" background="blue"></wa-qr-code>`
);
it('sets the correct background for the qr code', async () => {
const qrCode = await fixture<WaQrCode>(
html` <wa-qr-code value="test data" fill="red" background="blue"></wa-qr-code>`
);
expectQrCodeColorsToBe(qrCode, { foreground: red, background: blue });
});
expectQrCodeColorsToBe(qrCode, { foreground: red, background: blue });
});
it('has the expected size', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data" size="100"></wa-qr-code>`);
it('has the expected size', async () => {
const qrCode = await fixture<WaQrCode>(html` <wa-qr-code value="test data" size="100"></wa-qr-code>`);
const height = qrCode.getBoundingClientRect().height;
const width = qrCode.getBoundingClientRect().width;
expect(height).to.equal(100);
expect(width).to.equal(100);
});
const height = qrCode.getBoundingClientRect().height;
const width = qrCode.getBoundingClientRect().width;
expect(height).to.equal(100);
expect(width).to.equal(100);
});
});
}
});

View File

@@ -1,12 +1,13 @@
import { customElement, property, query } from 'lit/decorators.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import { watch } from '../../internal/watch.js';
import componentStyles from '../../styles/component.styles.js';
import QrCreator from 'qr-creator';
import styles from './qr-code.styles.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import type { CSSResultGroup } from 'lit';
import type { CSSResultGroup, PropertyValues } from 'lit';
import type _QrCreator from 'qr-creator';
let QrCreator: _QrCreator.default;
/**
* @summary Generates a [QR code](https://www.qrcode.com/) and renders it using the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
@@ -43,17 +44,38 @@ export default class WaQrCode extends WebAwesomeElement {
/** The level of error correction to use. [Learn more](https://www.qrcode.com/en/about/error_correction.html) */
@property({ attribute: 'error-correction' }) errorCorrection: 'L' | 'M' | 'Q' | 'H' = 'H';
firstUpdated() {
this.generate();
/**
* Whether or not the qr-code generated.
*/
// @ts-expect-error Don't know why it marks it as unused.
@state() private generated = false;
firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
if (this.hasUpdated) {
this.generate();
}
}
@watch(['background', 'errorCorrection', 'fill', 'radius', 'size', 'value'])
generate() {
this.style.setProperty('--size', `${this.size}px`);
if (!this.hasUpdated) {
return;
}
(QrCreator as unknown as typeof QrCreator.default).render(
// We lazy load because the QR generator will cause the server to crash, but we want to reduce layout shift.
if (!QrCreator) {
import('qr-creator').then(mod => {
QrCreator = mod.default;
this.generate();
});
return;
}
(QrCreator as unknown as typeof _QrCreator.default).render(
{
text: this.value,
radius: this.radius,
@@ -65,6 +87,8 @@ export default class WaQrCode extends WebAwesomeElement {
},
this.canvas
);
this.generated = true;
}
render() {
@@ -74,10 +98,6 @@ export default class WaQrCode extends WebAwesomeElement {
class="qr-code"
role="img"
aria-label=${this.label?.length > 0 ? this.label : this.value}
style=${styleMap({
width: `${this.size}px`,
height: `${this.size}px`
})}
></canvas>
`;
}

View File

@@ -1,44 +1,55 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaRadioButton from './radio-button.js';
import type WaRadioGroup from '../radio-group/radio-group.js';
describe('<wa-radio-button>', () => {
it('should not get checked when disabled', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio-button id="radio-1" value="1"></wa-radio-button>
<wa-radio-button id="radio-2" value="2" disabled></wa-radio-button>
</wa-radio-group>
`);
const radio1 = radioGroup.querySelector<WaRadioButton>('#radio-1')!;
const radio2 = radioGroup.querySelector<WaRadioButton>('#radio-2')!;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should not get checked when disabled', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio-button id="radio-1" value="1"></wa-radio-button>
<wa-radio-button id="radio-2" value="2" disabled></wa-radio-button>
</wa-radio-group>
`);
const radio1 = radioGroup.querySelector<WaRadioButton>('#radio-1')!;
const radio2 = radioGroup.querySelector<WaRadioButton>('#radio-2')!;
radio2.click();
await Promise.all([radio1.updateComplete, radio2.updateComplete]);
radio2.click();
await Promise.all([radio1.updateComplete, radio2.updateComplete]);
expect(radio1.checked).to.be.true;
expect(radio2.checked).to.be.false;
});
expect(radio1.checked).to.be.true;
expect(radio2.checked).to.be.false;
});
it('should receive positional classes from <wa-button-group>', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio-button id="radio-1" value="1"></wa-radio-button>
<wa-radio-button id="radio-2" value="2"></wa-radio-button>
<wa-radio-button id="radio-3" value="3"></wa-radio-button>
</wa-radio-group>
`);
const radio1 = radioGroup.querySelector<WaRadioButton>('#radio-1')!;
const radio2 = radioGroup.querySelector<WaRadioButton>('#radio-2')!;
const radio3 = radioGroup.querySelector<WaRadioButton>('#radio-3')!;
it('should receive positional classes from <wa-button-group>', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio-button id="radio-1" value="1"></wa-radio-button>
<wa-radio-button id="radio-2" value="2"></wa-radio-button>
<wa-radio-button id="radio-3" value="3"></wa-radio-button>
</wa-radio-group>
`);
const radio1 = radioGroup.querySelector<WaRadioButton>('#radio-1')!;
const radio2 = radioGroup.querySelector<WaRadioButton>('#radio-2')!;
const radio3 = radioGroup.querySelector<WaRadioButton>('#radio-3')!;
await Promise.all([radioGroup.updateComplete, radio1.updateComplete, radio2.updateComplete, radio3.updateComplete]);
await Promise.all([
radioGroup.updateComplete,
radio1.updateComplete,
radio2.updateComplete,
radio3.updateComplete
]);
expect(radio1.classList.contains('wa-button-group__button')).to.be.true;
expect(radio1.classList.contains('wa-button-group__button--first')).to.be.true;
expect(radio2.classList.contains('wa-button-group__button')).to.be.true;
expect(radio2.classList.contains('wa-button-group__button--inner')).to.be.true;
expect(radio3.classList.contains('wa-button-group__button')).to.be.true;
expect(radio3.classList.contains('wa-button-group__button--last')).to.be.true;
});
expect(radio1.classList.contains('wa-button-group__button')).to.be.true;
expect(radio1.classList.contains('wa-button-group__button--first')).to.be.true;
expect(radio2.classList.contains('wa-button-group__button')).to.be.true;
expect(radio2.classList.contains('wa-button-group__button--inner')).to.be.true;
expect(radio3.classList.contains('wa-button-group__button')).to.be.true;
expect(radio3.classList.contains('wa-button-group__button--last')).to.be.true;
});
});
}
});

View File

@@ -84,6 +84,21 @@ export default class WaRadioButton extends WebAwesomeFormAssociatedElement {
*/
@property({ reflect: true }) form: string | null = null;
/**
* Used for SSR. if true, will show slotted prefix on initial render.
*/
@property({ type: Boolean, attribute: 'with-prefix' }) withPrefix = false;
/**
* Used for SSR. if true, will show slotted suffix on initial render.
*/
@property({ type: Boolean, attribute: 'with-suffix' }) withSuffix = false;
/**
* Used for SSR. if true, will show slotted suffix on initial render. (should this be withDefault, since its the default slot??)
*/
@property({ type: Boolean, attribute: 'with-label' }) withLabel = false;
// Needed for Form Validation. Without it we get a console error.
static shadowRootOptions = { ...WebAwesomeFormAssociatedElement.shadowRootOptions, delegatesFocus: true };
@@ -128,6 +143,10 @@ export default class WaRadioButton extends WebAwesomeFormAssociatedElement {
}
render() {
const hasLabel = this.hasUpdated ? this.hasSlotController.test('[default]') : this.withLabel;
const hasPrefix = this.hasUpdated ? this.hasSlotController.test('prefix') : this.withPrefix;
const hasSuffix = this.hasUpdated ? this.hasSlotController.test('suffix') : this.withSuffix;
return html`
<div part="base" role="presentation">
<button
@@ -146,9 +165,9 @@ export default class WaRadioButton extends WebAwesomeFormAssociatedElement {
'button--focused': this.hasFocus,
'button--outlined': true,
'button--pill': this.pill,
'button--has-label': this.hasSlotController.test('[default]'),
'button--has-prefix': this.hasSlotController.test('prefix'),
'button--has-suffix': this.hasSlotController.test('suffix')
'button--has-label': hasLabel,
'button--has-prefix': hasPrefix,
'button--has-suffix': hasSuffix
})}
aria-disabled=${this.disabled}
type="button"

View File

@@ -1,5 +1,7 @@
import { aTimeout, expect, fixture, html, oneEvent } from '@open-wc/testing';
import { aTimeout, expect, oneEvent } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
@@ -8,403 +10,407 @@ import type WaRadio from '../radio/radio.js';
import type WaRadioGroup from './radio-group.js';
describe('<wa-radio-group>', () => {
describe('validation tests', () => {
it('should be invalid initially when required and no radio is checked', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
`);
runFormControlBaseTests('wa-radio-group');
expect(radioGroup.checkValidity()).to.be.false;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('validation tests', () => {
it('should be invalid initially when required and no radio is checked', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
`);
expect(radioGroup.checkValidity()).to.be.false;
});
it('should become valid when an option is checked', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
`);
radioGroup.value = '1';
await radioGroup.updateComplete;
expect(radioGroup.checkValidity()).to.be.true;
});
it(`should be valid when required and one radio is checked`, async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group label="Select an option" value="1" required>
<wa-radio name="option" value="1">Option 1</wa-radio>
<wa-radio name="option" value="2">Option 2</wa-radio>
<wa-radio name="option" value="3">Option 3</wa-radio>
</wa-radio-group>
`);
expect(el.checkValidity()).to.be.true;
});
it(`should be invalid when required and no radios are checked`, async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group label="Select an option" required>
<wa-radio name="option" value="1">Option 1</wa-radio>
<wa-radio name="option" value="2">Option 2</wa-radio>
<wa-radio name="option" value="3">Option 3</wa-radio>
</wa-radio-group>
`);
expect(el.checkValidity()).to.be.false;
});
it(`should be valid when required and a different radio is checked`, async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group label="Select an option" value="3" required>
<wa-radio name="option" value="1">Option 1</wa-radio>
<wa-radio name="option" value="2">Option 2</wa-radio>
<wa-radio name="option" value="3">Option 3</wa-radio>
</wa-radio-group>
`);
expect(el.checkValidity()).to.be.true;
});
it(`should be invalid when custom validity is set`, async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group label="Select an option">
<wa-radio name="option" value="1">Option 1</wa-radio>
<wa-radio name="option" value="2">Option 2</wa-radio>
<wa-radio name="option" value="3">Option 3</wa-radio>
</wa-radio-group>
`);
el.setCustomValidity('Error');
expect(el.checkValidity()).to.be.false;
});
it('should receive the correct validation attributes ("states") when valid', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1" required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
`);
const secondRadio = radioGroup.querySelectorAll('wa-radio')[1];
expect(radioGroup.checkValidity()).to.be.true;
expect(radioGroup.hasAttribute('data-wa-required')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-optional')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-valid')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(secondRadio);
await secondRadio.updateComplete;
expect(radioGroup.checkValidity()).to.be.true;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-valid')).to.be.true;
});
it('should receive the correct validation attributes ("states") when invalid', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
`);
const secondRadio = radioGroup.querySelectorAll('wa-radio')[1];
expect(radioGroup.hasAttribute('data-wa-required')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-optional')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-invalid')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-valid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(secondRadio);
radioGroup.value = '';
await radioGroup.updateComplete;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-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>
<wa-radio-group required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
</form>
`);
const radioGroup = el.querySelector<WaRadioGroup>('wa-radio-group')!;
expect(radioGroup.hasAttribute('data-wa-required')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-optional')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-invalid')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-valid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-valid')).to.be.false;
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-radio-group value="1">
<wa-radio id="radio-1" name="a" value="1"></wa-radio>
<wa-radio id="radio-2" name="a" value="2"></wa-radio>
</wa-radio-group>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const radioGroup = form.querySelector<WaRadioGroup>('wa-radio-group')!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
// Submitting the form after setting custom validity should not trigger the handler
radioGroup.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
await aTimeout(100);
expect(submitHandler).to.not.have.been.called;
});
});
});
it('should become valid when an option is checked', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
`);
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-radio-group value="1">
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
<wa-button type="reset">Reset</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const radioGroup = form.querySelector('wa-radio-group')!;
radioGroup.value = '2';
radioGroup.value = '1';
await radioGroup.updateComplete;
await radioGroup.updateComplete;
setTimeout(() => button.click());
expect(radioGroup.checkValidity()).to.be.true;
await oneEvent(form, 'reset');
await radioGroup.updateComplete;
expect(radioGroup.value).to.equal('1');
});
});
it(`should be valid when required and one radio is checked`, async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group label="Select an option" value="1" required>
<wa-radio name="option" value="1">Option 1</wa-radio>
<wa-radio name="option" value="2">Option 2</wa-radio>
<wa-radio name="option" value="3">Option 3</wa-radio>
</wa-radio-group>
`);
describe('when submitting a form', () => {
it('should submit the correct value when a value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-radio-group name="a" value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
<wa-radio id="radio-3" value="3"></wa-radio>
</wa-radio-group>
</form>
`);
expect(el.checkValidity()).to.be.true;
const radio = form.querySelectorAll('wa-radio')[1];
radio.click();
await form.querySelector('wa-radio-group')?.updateComplete;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('2');
});
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>
<form id="f">
<wa-button type="submit">Submit</wa-button>
</form>
<wa-radio-group form="f" name="a" value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
<wa-radio id="radio-3" value="3"></wa-radio>
</wa-radio-group>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
});
it(`should be invalid when required and no radios are checked`, async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group label="Select an option" required>
<wa-radio name="option" value="1">Option 1</wa-radio>
<wa-radio name="option" value="2">Option 2</wa-radio>
<wa-radio name="option" value="3">Option 3</wa-radio>
</wa-radio-group>
`);
expect(el.checkValidity()).to.be.false;
});
it(`should be valid when required and a different radio is checked`, async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group label="Select an option" value="3" required>
<wa-radio name="option" value="1">Option 1</wa-radio>
<wa-radio name="option" value="2">Option 2</wa-radio>
<wa-radio name="option" value="3">Option 3</wa-radio>
</wa-radio-group>
`);
expect(el.checkValidity()).to.be.true;
});
it(`should be invalid when custom validity is set`, async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group label="Select an option">
<wa-radio name="option" value="1">Option 1</wa-radio>
<wa-radio name="option" value="2">Option 2</wa-radio>
<wa-radio name="option" value="3">Option 3</wa-radio>
</wa-radio-group>
`);
el.setCustomValidity('Error');
expect(el.checkValidity()).to.be.false;
});
it('should receive the correct validation attributes ("states") when valid', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1" required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
`);
const secondRadio = radioGroup.querySelectorAll('wa-radio')[1];
expect(radioGroup.checkValidity()).to.be.true;
expect(radioGroup.hasAttribute('data-wa-required')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-optional')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-valid')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(secondRadio);
await secondRadio.updateComplete;
expect(radioGroup.checkValidity()).to.be.true;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-valid')).to.be.true;
});
it('should receive the correct validation attributes ("states") when invalid', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
`);
const secondRadio = radioGroup.querySelectorAll('wa-radio')[1];
expect(radioGroup.hasAttribute('data-wa-required')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-optional')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-invalid')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-valid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(secondRadio);
radioGroup.value = '';
await radioGroup.updateComplete;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-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>
<wa-radio-group required>
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
describe('when a size is applied', () => {
it('should apply the same size to all radios', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group size="large">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
</form>
`);
const radioGroup = el.querySelector<WaRadioGroup>('wa-radio-group')!;
`);
const [radio1, radio2] = radioGroup.querySelectorAll('wa-radio');
expect(radioGroup.hasAttribute('data-wa-required')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-optional')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-invalid')).to.be.true;
expect(radioGroup.hasAttribute('data-wa-valid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(radioGroup.hasAttribute('data-wa-user-valid')).to.be.false;
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
it('should apply the same size to all radio buttons', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group size="large">
<wa-radio-button id="radio-1" value="1"></wa-radio-button>
<wa-radio-button id="radio-2" value="2"></wa-radio-button>
</wa-radio-group>
`);
const [radio1, radio2] = radioGroup.querySelectorAll('wa-radio-button');
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
it('should update the size of all radio buttons when size changes', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group size="small">
<wa-radio-button id="radio-1" value="1"></wa-radio-button>
<wa-radio-button id="radio-2" value="2"></wa-radio-button>
</wa-radio-group>
`);
const [radio1, radio2] = radioGroup.querySelectorAll('wa-radio-button');
expect(radio1.size).to.equal('small');
expect(radio2.size).to.equal('small');
radioGroup.size = 'large';
await radioGroup.updateComplete;
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
describe('when the value changes', () => {
it('should emit wa-change when toggled with the arrow keys', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
`);
const firstRadio = radioGroup.querySelector<WaRadio>('#radio-1')!;
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
radioGroup.addEventListener('wa-change', changeHandler);
radioGroup.addEventListener('wa-input', inputHandler);
firstRadio.focus();
await sendKeys({ press: 'ArrowRight' });
await radioGroup.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(radioGroup.value).to.equal('2');
});
it('should emit wa-change and wa-input when clicked', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
`);
const radio = radioGroup.querySelector<WaRadio>('#radio-1')!;
setTimeout(() => radio.click());
const event = (await oneEvent(radioGroup, 'wa-change')) as WaChangeEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
it('should emit wa-change and wa-input when toggled with spacebar', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
`);
const radio = radioGroup.querySelector<WaRadio>('#radio-1')!;
radio.focus();
setTimeout(() => sendKeys({ press: ' ' }));
const event = (await oneEvent(radioGroup, 'wa-change')) as WaChangeEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
it('should not emit wa-change or wa-input when the value is changed programmatically', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio id="radio-1" name="a" value="1"></wa-radio>
<wa-radio id="radio-2" name="a" value="2"></wa-radio>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const radioGroup = form.querySelector<WaRadioGroup>('wa-radio-group')!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
`);
// Submitting the form after setting custom validity should not trigger the handler
radioGroup.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
radioGroup.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
radioGroup.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
radioGroup.value = '2';
await radioGroup.updateComplete;
});
await aTimeout(100);
// I think we can delete this?? We no longer need to have a hidden form control to mimic formAssociation.
it.skip('should relatively position content to prevent visually hidden scroll bugs', async () => {
//
// See https://github.com/shoelace-style/shoelace/issues/1380
//
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
</wa-radio-group>
`);
expect(submitHandler).to.not.have.been.called;
const formControl = radioGroup.shadowRoot!.querySelector('.form-control')!;
const visuallyHidden = radioGroup.shadowRoot!.querySelector('.visually-hidden')!;
expect(getComputedStyle(formControl).position).to.equal('relative');
expect(getComputedStyle(visuallyHidden).position).to.equal('absolute');
});
/**
* @see https://github.com/shoelace-style/shoelace/issues/1361
* This isn't really possible to test right now due to importing "shoelace.js" which
* auto-defines all of our components up front. This should be tested if we ever split
* to non-auto-defining components and not auto-defining for tests.
*/
it.skip('should sync up radios and radio buttons if defined after radio group', async () => {
// customElements.define("wa-radio-group", WaRadioGroup)
//
// const radioGroup = await fixture<WaRadioGroup>(html`
// <wa-radio-group value="1">
// <wa-radio id="radio-1" value="1"></wa-radio>
// <wa-radio id="radio-2" value="2"></wa-radio>
// </wa-radio-group>
// `);
//
// await aTimeout(1)
//
// customElements.define("wa-radio-button", WaRadioButton)
//
// expect(radioGroup.querySelector("wa-radio")?.getAttribute("aria-checked")).to.equal("false")
//
// await aTimeout(1)
//
// customElements.define("wa-radio", WaRadio)
//
// await aTimeout(1)
//
// expect(radioGroup.querySelector("wa-radio")?.getAttribute("aria-checked")).to.equal("true")
});
});
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-radio-group value="1">
<wa-radio value="1"></wa-radio>
<wa-radio value="2"></wa-radio>
</wa-radio-group>
<wa-button type="reset">Reset</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const radioGroup = form.querySelector('wa-radio-group')!;
radioGroup.value = '2';
await radioGroup.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await radioGroup.updateComplete;
expect(radioGroup.value).to.equal('1');
});
});
describe('when submitting a form', () => {
it('should submit the correct value when a value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-radio-group name="a" value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
<wa-radio id="radio-3" value="3"></wa-radio>
</wa-radio-group>
</form>
`);
const radio = form.querySelectorAll('wa-radio')[1];
radio.click();
await form.querySelector('wa-radio-group')?.updateComplete;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('2');
});
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>
<form id="f">
<wa-button type="submit">Submit</wa-button>
</form>
<wa-radio-group form="f" name="a" value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
<wa-radio id="radio-3" value="3"></wa-radio>
</wa-radio-group>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
});
describe('when a size is applied', () => {
it('should apply the same size to all radios', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group size="large">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
`);
const [radio1, radio2] = radioGroup.querySelectorAll('wa-radio');
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
it('should apply the same size to all radio buttons', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group size="large">
<wa-radio-button id="radio-1" value="1"></wa-radio-button>
<wa-radio-button id="radio-2" value="2"></wa-radio-button>
</wa-radio-group>
`);
const [radio1, radio2] = radioGroup.querySelectorAll('wa-radio-button');
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
it('should update the size of all radio buttons when size changes', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group size="small">
<wa-radio-button id="radio-1" value="1"></wa-radio-button>
<wa-radio-button id="radio-2" value="2"></wa-radio-button>
</wa-radio-group>
`);
const [radio1, radio2] = radioGroup.querySelectorAll('wa-radio-button');
expect(radio1.size).to.equal('small');
expect(radio2.size).to.equal('small');
radioGroup.size = 'large';
await radioGroup.updateComplete;
expect(radio1.size).to.equal('large');
expect(radio2.size).to.equal('large');
});
});
describe('when the value changes', async () => {
it('should emit wa-change when toggled with the arrow keys', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
`);
const firstRadio = radioGroup.querySelector<WaRadio>('#radio-1')!;
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
radioGroup.addEventListener('wa-change', changeHandler);
radioGroup.addEventListener('wa-input', inputHandler);
firstRadio.focus();
await sendKeys({ press: 'ArrowRight' });
await radioGroup.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(radioGroup.value).to.equal('2');
});
it('should emit wa-change and wa-input when clicked', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
`);
const radio = radioGroup.querySelector<WaRadio>('#radio-1')!;
setTimeout(() => radio.click());
const event = (await oneEvent(radioGroup, 'wa-change')) as WaChangeEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
it('should emit wa-change and wa-input when toggled with spacebar', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
`);
const radio = radioGroup.querySelector<WaRadio>('#radio-1')!;
radio.focus();
setTimeout(() => sendKeys({ press: ' ' }));
const event = (await oneEvent(radioGroup, 'wa-change')) as WaChangeEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
it('should not emit wa-change or wa-input when the value is changed programmatically', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
</wa-radio-group>
`);
radioGroup.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
radioGroup.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
radioGroup.value = '2';
await radioGroup.updateComplete;
});
// I think we can delete this?? We no longer need to have a hidden form control to mimic formAssociation.
it.skip('should relatively position content to prevent visually hidden scroll bugs', async () => {
//
// See https://github.com/shoelace-style/shoelace/issues/1380
//
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
</wa-radio-group>
`);
const formControl = radioGroup.shadowRoot!.querySelector('.form-control')!;
const visuallyHidden = radioGroup.shadowRoot!.querySelector('.visually-hidden')!;
expect(getComputedStyle(formControl).position).to.equal('relative');
expect(getComputedStyle(visuallyHidden).position).to.equal('absolute');
});
/**
* @see https://github.com/shoelace-style/shoelace/issues/1361
* This isn't really possible to test right now due to importing "shoelace.js" which
* auto-defines all of our components up front. This should be tested if we ever split
* to non-auto-defining components and not auto-defining for tests.
*/
it.skip('should sync up radios and radio buttons if defined after radio group', async () => {
// customElements.define("wa-radio-group", WaRadioGroup)
//
// const radioGroup = await fixture<WaRadioGroup>(html`
// <wa-radio-group value="1">
// <wa-radio id="radio-1" value="1"></wa-radio>
// <wa-radio id="radio-2" value="2"></wa-radio>
// </wa-radio-group>
// `);
//
// await aTimeout(1)
//
// customElements.define("wa-radio-button", WaRadioButton)
//
// expect(radioGroup.querySelector("wa-radio")?.getAttribute("aria-checked")).to.equal("false")
//
// await aTimeout(1)
//
// customElements.define("wa-radio", WaRadio)
//
// await aTimeout(1)
//
// expect(radioGroup.querySelector("wa-radio")?.getAttribute("aria-checked")).to.equal("true")
});
await runFormControlBaseTests('wa-radio-group');
}
});

View File

@@ -3,7 +3,7 @@ import '../radio/radio.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { RequiredValidator } from '../../internal/validators/required-validator.js';
import { uniqueId } from '../../internal/math.js';
import { WaChangeEvent } from '../../events/change.js';
@@ -46,17 +46,19 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
static styles: CSSResultGroup = [componentStyles, formControlStyles, styles];
static get validators() {
return [
...super.validators,
RequiredValidator({
validationElement: Object.assign(document.createElement('input'), {
required: true,
type: 'radio',
// we need an id that's guaranteed to be unique; users will never see this
name: uniqueId('__wa-radio')
})
})
];
const validators = isServer
? []
: [
RequiredValidator({
validationElement: Object.assign(document.createElement('input'), {
required: true,
type: 'radio',
// we need an id that's guaranteed to be unique; users will never see this
name: uniqueId('__wa-radio')
})
})
];
return [...super.validators, ...validators];
}
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
@@ -77,8 +79,29 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
/** The name of the radio group, submitted as a name/value pair with form data. */
@property({ reflect: true }) name: string | null = null;
@property({ attribute: false }) value = '';
@property({ attribute: 'value', reflect: true }) defaultValue = '';
private _value: string | null = null;
get value() {
if (this.valueHasChanged) {
return this._value;
}
return this._value ?? this.defaultValue;
}
/** The current value of the radio group, submitted as a name/value pair with form data. */
@state()
set value(val: string | null) {
if (this._value === val) {
return;
}
this.valueHasChanged = true;
this._value = val;
}
/** The default value of the form control. Primarily used for resetting the form control. */
@property({ attribute: 'value', reflect: true }) defaultValue: null | string = this.getAttribute('value') || null;
/** The radio group's size. This size will be applied to all child radios and radio buttons. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@@ -86,6 +109,16 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
/**
* Used for SSR. if true, will show slotted label on initial render.
*/
@property({ type: Boolean, attribute: 'with-label' }) withLabel = false;
/**
* Used for SSR. if true, will show slotted help text on initial render.
*/
@property({ type: Boolean, attribute: 'with-help-text' }) withHelpText = false;
//
// We need this because if we don't have it, FormValidation yells at us that it's "not focusable".
// If we use `this.tabIndex = -1` we can't focus the radio inside.
@@ -95,8 +128,10 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
constructor() {
super();
this.addEventListener('keydown', this.handleKeyDown);
this.addEventListener('click', this.handleRadioClick);
if (!isServer) {
this.addEventListener('keydown', this.handleKeyDown);
this.addEventListener('click', this.handleRadioClick);
}
}
private handleRadioClick = (e: Event) => {
@@ -190,7 +225,9 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
* the first radio element.
*/
get validationTarget() {
return this.querySelector<WaRadio | WaRadioButton>(':is(wa-radio, wa-radio-button):not([disabled])') || undefined;
return isServer
? undefined
: this.querySelector<WaRadio | WaRadioButton>(':is(wa-radio, wa-radio-button):not([disabled])') || undefined;
}
@watch('value')
@@ -269,8 +306,8 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabelSlot = this.hasUpdated ? this.hasSlotController.test('label') : this.withLabel;
const hasHelpTextSlot = this.hasUpdated ? this.hasSlotController.test('help-text') : this.withHelpText;
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
const defaultSlot = html` <slot @slotchange=${this.syncRadioElements}></slot> `;

View File

@@ -1,22 +1,28 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaRadio from './radio.js';
import type WaRadioGroup from '../radio-group/radio-group.js';
describe('<wa-radio>', () => {
it('should not get checked when disabled', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2" disabled></wa-radio>
</wa-radio-group>
`);
const radio1 = radioGroup.querySelector<WaRadio>('#radio-1')!;
const radio2 = radioGroup.querySelector<WaRadio>('#radio-2')!;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should not get checked when disabled', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`
<wa-radio-group value="1">
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2" disabled></wa-radio>
</wa-radio-group>
`);
const radio1 = radioGroup.querySelector<WaRadio>('#radio-1')!;
const radio2 = radioGroup.querySelector<WaRadio>('#radio-2')!;
radio2.click();
await Promise.all([radio1.updateComplete, radio2.updateComplete]);
radio2.click();
await Promise.all([radio1.updateComplete, radio2.updateComplete]);
expect(radio1.checked).to.be.true;
expect(radio2.checked).to.be.false;
});
expect(radio1.checked).to.be.true;
expect(radio2.checked).to.be.false;
});
});
}
});

View File

@@ -1,7 +1,7 @@
import '../icon/icon.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, state } from 'lit/decorators.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { WaBlurEvent } from '../../events/blur.js';
import { WaFocusEvent } from '../../events/focus.js';
import { watch } from '../../internal/watch.js';
@@ -66,9 +66,11 @@ export default class WaRadio extends WebAwesomeFormAssociatedElement {
constructor() {
super();
this.addEventListener('click', this.handleClick);
this.addEventListener('blur', this.handleBlur);
this.addEventListener('focus', this.handleFocus);
if (!isServer) {
this.addEventListener('click', this.handleClick);
this.addEventListener('blur', this.handleBlur);
this.addEventListener('focus', this.handleFocus);
}
}
connectedCallback() {

View File

@@ -1,235 +1,241 @@
import { clickOnElement } from '../../internal/test.js';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { expect, oneEvent } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import { sendKeys } from '@web/test-runner-commands';
import { serialize } from '../../utilities/form.js';
import sinon from 'sinon';
import type WaRange from './range.js';
describe('<wa-range>', async () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaRange>(html` <wa-range label="Name"></wa-range> `);
await expect(el).to.be.accessible();
});
describe('<wa-range>', () => {
runFormControlBaseTests('wa-range');
it('default properties', async () => {
const el = await fixture<WaRange>(html` <wa-range></wa-range> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaRange>(html`<wa-range label="Name"></wa-range>`);
await expect(el).to.be.accessible();
});
expect(el.name).to.equal('');
expect(el.value).to.equal(0);
expect(el.title).to.equal('');
expect(el.label).to.equal('');
expect(el.helpText).to.equal('');
expect(el.disabled).to.be.false;
expect(el.checkValidity()).to.be.true;
expect(el.min).to.equal(0);
expect(el.max).to.equal(100);
expect(el.step).to.equal(1);
expect(el.tooltip).to.equal('top');
expect(el.defaultValue).to.equal(0);
});
it('default properties', async () => {
const el = await fixture<WaRange>(html` <wa-range></wa-range> `);
it('should have title if title attribute is set', async () => {
const el = await fixture<WaRange>(html` <wa-range title="Test"></wa-range> `);
const input = el.shadowRoot!.querySelector('input')!;
expect(el.name).to.equal('');
expect(el.value).to.equal(0);
expect(el.title).to.equal('');
expect(el.label).to.equal('');
expect(el.helpText).to.equal('');
expect(el.disabled).to.be.false;
expect(el.checkValidity()).to.be.true;
expect(el.min).to.equal(0);
expect(el.max).to.equal(100);
expect(el.step).to.equal(1);
expect(el.tooltip).to.equal('top');
expect(el.defaultValue).to.equal(0);
});
expect(input.title).to.equal('Test');
});
it('should have title if title attribute is set', async () => {
const el = await fixture<WaRange>(html` <wa-range title="Test"></wa-range> `);
const input = el.shadowRoot!.querySelector('input')!;
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaRange>(html` <wa-range disabled></wa-range> `);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('[part~="input"]')!;
expect(input.title).to.equal('Test');
});
expect(input.disabled).to.be.true;
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaRange>(html` <wa-range disabled></wa-range> `);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('[part~="input"]')!;
describe('when the value changes', () => {
it('should emit wa-change and wa-input when the value changes from clicking the slider', async () => {
const el = await fixture<WaRange>(html` <wa-range value="0"></wa-range> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
expect(input.disabled).to.be.true;
});
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
await clickOnElement(el, 'right');
await el.updateComplete;
describe('when the value changes', () => {
it('should emit wa-change and wa-input when the value changes from clicking the slider', async () => {
const el = await fixture<WaRange>(html` <wa-range value="0"></wa-range> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
expect(el.value).to.equal(100);
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
await clickOnElement(el, 'right');
await el.updateComplete;
expect(el.value).to.equal(100);
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
});
it('should emit wa-change and wa-input and decrease the value when pressing left arrow', async () => {
const el = await fixture<WaRange>(html` <wa-range value="50"></wa-range> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: 'ArrowLeft' });
await el.updateComplete;
expect(el.value).to.equal(49);
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
});
it('should emit wa-change and wa-input and decrease the value when pressing right arrow', async () => {
const el = await fixture<WaRange>(html` <wa-range value="50"></wa-range> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: 'ArrowRight' });
await el.updateComplete;
expect(el.value).to.equal(51);
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
});
it('should not emit wa-change or wa-input when changing the value programmatically', async () => {
const el = await fixture<WaRange>(html` <wa-range value="0"></wa-range> `);
el.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
el.value = 50;
await el.updateComplete;
});
it('should not emit wa-change or wa-input when stepUp() is called programmatically', async () => {
const el = await fixture<WaRange>(html` <wa-range step="2" value="2"></wa-range> `);
el.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
el.stepUp();
await el.updateComplete;
});
it('should not emit wa-change or wa-input when stepDown() is called programmatically', async () => {
const el = await fixture<WaRange>(html` <wa-range step="2" value="2"></wa-range> `);
el.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
el.stepDown();
await el.updateComplete;
});
});
describe('step', () => {
it('should increment by step when stepUp() is called', async () => {
const el = await fixture<WaRange>(html` <wa-range step="2" value="2"></wa-range> `);
el.stepUp();
await el.updateComplete;
expect(el.value).to.equal(4);
});
it('should decrement by step when stepDown() is called', async () => {
const el = await fixture<WaRange>(html` <wa-range step="2" value="2"></wa-range> `);
el.stepDown();
await el.updateComplete;
expect(el.value).to.equal(0);
});
});
describe('when submitting a form', () => {
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html` <form><wa-range name="a" value="1"></wa-range></form> `);
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
it('should serialize its name and value with JSON', async () => {
const form = await fixture<HTMLFormElement>(html` <form><wa-range name="a" value="1"></wa-range></form> `);
const json = serialize(form);
expect(json.a).to.equal('1');
});
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const range = await fixture<HTMLFormElement>(html` <wa-range></wa-range> `);
range.setCustomValidity('Invalid selection');
await range.updateComplete;
expect(range.checkValidity()).to.be.false;
expect(range.hasAttribute('data-wa-invalid')).to.be.true;
expect(range.hasAttribute('data-wa-valid')).to.be.false;
expect(range.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(range.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(range);
await range.updateComplete;
range.blur();
await range.updateComplete;
expect(range.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(range.hasAttribute('data-wa-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><wa-range></wa-range></form> `);
const range = el.querySelector<WaRange>('wa-range')!;
range.setCustomValidity('Invalid value');
await range.updateComplete;
expect(range.hasAttribute('data-wa-invalid')).to.be.true;
expect(range.hasAttribute('data-wa-valid')).to.be.false;
expect(range.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(range.hasAttribute('data-wa-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>
<form id="f">
<wa-button type="submit">Submit</wa-button>
</form>
<wa-range form="f" name="a" value="50"></wa-range>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('50');
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-range name="a" value="99"></wa-range>
<wa-button type="reset">Reset</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const input = form.querySelector('wa-range')!;
input.value = 80;
await input.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await input.updateComplete;
expect(input.value).to.equal(99);
input.defaultValue = 0;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await input.updateComplete;
expect(input.value).to.equal(0);
});
});
});
it('should emit wa-change and wa-input and decrease the value when pressing left arrow', async () => {
const el = await fixture<WaRange>(html` <wa-range value="50"></wa-range> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: 'ArrowLeft' });
await el.updateComplete;
expect(el.value).to.equal(49);
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
});
it('should emit wa-change and wa-input and decrease the value when pressing right arrow', async () => {
const el = await fixture<WaRange>(html` <wa-range value="50"></wa-range> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: 'ArrowRight' });
await el.updateComplete;
expect(el.value).to.equal(51);
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
});
it('should not emit wa-change or wa-input when changing the value programmatically', async () => {
const el = await fixture<WaRange>(html` <wa-range value="0"></wa-range> `);
el.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
el.value = 50;
await el.updateComplete;
});
it('should not emit wa-change or wa-input when stepUp() is called programmatically', async () => {
const el = await fixture<WaRange>(html` <wa-range step="2" value="2"></wa-range> `);
el.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
el.stepUp();
await el.updateComplete;
});
it('should not emit wa-change or wa-input when stepDown() is called programmatically', async () => {
const el = await fixture<WaRange>(html` <wa-range step="2" value="2"></wa-range> `);
el.addEventListener('wa-change', () => expect.fail('wa-change should not be emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-input should not be emitted'));
el.stepDown();
await el.updateComplete;
});
});
describe('step', () => {
it('should increment by step when stepUp() is called', async () => {
const el = await fixture<WaRange>(html` <wa-range step="2" value="2"></wa-range> `);
el.stepUp();
await el.updateComplete;
expect(el.value).to.equal(4);
});
it('should decrement by step when stepDown() is called', async () => {
const el = await fixture<WaRange>(html` <wa-range step="2" value="2"></wa-range> `);
el.stepDown();
await el.updateComplete;
expect(el.value).to.equal(0);
});
});
describe('when submitting a form', () => {
it('should serialize its name and value with FormData', async () => {
const form = await fixture<HTMLFormElement>(html` <form><wa-range name="a" value="1"></wa-range></form> `);
const formData = new FormData(form);
expect(formData.get('a')).to.equal('1');
});
it('should serialize its name and value with JSON', async () => {
const form = await fixture<HTMLFormElement>(html` <form><wa-range name="a" value="1"></wa-range></form> `);
const json = serialize(form);
expect(json.a).to.equal('1');
});
it('should be invalid when setCustomValidity() is called with a non-empty value', async () => {
const range = await fixture<HTMLFormElement>(html` <wa-range></wa-range> `);
range.setCustomValidity('Invalid selection');
await range.updateComplete;
expect(range.checkValidity()).to.be.false;
expect(range.hasAttribute('data-wa-invalid')).to.be.true;
expect(range.hasAttribute('data-wa-valid')).to.be.false;
expect(range.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(range.hasAttribute('data-wa-user-valid')).to.be.false;
await clickOnElement(range);
await range.updateComplete;
range.blur();
await range.updateComplete;
expect(range.hasAttribute('data-wa-user-invalid')).to.be.true;
expect(range.hasAttribute('data-wa-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><wa-range></wa-range></form> `);
const range = el.querySelector<WaRange>('wa-range')!;
range.setCustomValidity('Invalid value');
await range.updateComplete;
expect(range.hasAttribute('data-wa-invalid')).to.be.true;
expect(range.hasAttribute('data-wa-valid')).to.be.false;
expect(range.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(range.hasAttribute('data-wa-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>
<form id="f">
<wa-button type="submit">Submit</wa-button>
</form>
<wa-range form="f" name="a" value="50"></wa-range>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
expect(formData.get('a')).to.equal('50');
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-range name="a" value="99"></wa-range>
<wa-button type="reset">Reset</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const input = form.querySelector('wa-range')!;
input.value = 80;
await input.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await input.updateComplete;
expect(input.value).to.equal(99);
input.defaultValue = 0;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await input.updateComplete;
expect(input.value).to.equal(0);
});
});
await runFormControlBaseTests('wa-range');
}
});

View File

@@ -72,11 +72,29 @@ export default class WaRange extends WebAwesomeFormAssociatedElement {
/** The name of the range, submitted as a name/value pair with form data. */
@property() name = '';
/** The current value of the range, submitted as a name/value pair with form data. */
@property({ attribute: false, type: Number }) value = 0;
/** The default value of the form control. Primarily used for resetting the form control. */
@property({ type: Number, attribute: 'value', reflect: true }) defaultValue = 0;
@property({ type: Number, attribute: 'value', reflect: true }) defaultValue = Number(this.getAttribute('value')) || 0;
private _value: number | null = null;
/** The current value of the range, submitted as a name/value pair with form data. */
get value(): number {
if (this.valueHasChanged) {
return this._value || 0;
}
return this._value ?? (this.defaultValue || 0);
}
@state()
set value(val: number | null) {
if (this._value === val) {
return;
}
this.valueHasChanged = true;
this._value = val;
}
/** The range's label. If you need to display HTML, use the `label` slot instead. */
@property() label = '';
@@ -112,6 +130,16 @@ export default class WaRange extends WebAwesomeFormAssociatedElement {
*/
@property({ reflect: true }) form: null | string = null;
/**
* Used for SSR to render slotted labels. If true, will render slotted label content on first paint.
*/
@property({ attribute: 'with-label', reflect: true, type: Boolean }) withLabel = false;
/**
* Used for SSR to render slotted labels. If true, will render slotted help-text content on first paint.
*/
@property({ attribute: 'with-help-text', reflect: true, type: Boolean }) withHelpText = false;
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.syncRange());
@@ -246,8 +274,8 @@ export default class WaRange extends WebAwesomeFormAssociatedElement {
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabelSlot = this.hasUpdated ? this.hasSlotController.test('label') : this.withLabel;
const hasHelpTextSlot = this.hasUpdated ? this.hasSlotController.test('help-text') : this.withHelpText;
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;

View File

@@ -1,129 +1,135 @@
import { clickOnElement } from '../../internal/test.js';
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type WaRating from './rating.js';
describe('<wa-rating>', () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test"></wa-rating> `);
await expect(el).to.be.accessible();
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test"></wa-rating> `);
await expect(el).to.be.accessible();
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
expect(base.getAttribute('role')).to.equal('slider');
expect(base.getAttribute('aria-disabled')).to.equal('false');
expect(base.getAttribute('aria-readonly')).to.equal('false');
expect(base.getAttribute('aria-valuenow')).to.equal('0');
expect(base.getAttribute('aria-valuemin')).to.equal('0');
expect(base.getAttribute('aria-valuemax')).to.equal('5');
expect(base.getAttribute('tabindex')).to.equal('0');
expect(base.getAttribute('class')).to.equal(' rating ');
});
expect(base.getAttribute('role')).to.equal('slider');
expect(base.getAttribute('aria-disabled')).to.equal('false');
expect(base.getAttribute('aria-readonly')).to.equal('false');
expect(base.getAttribute('aria-valuenow')).to.equal('0');
expect(base.getAttribute('aria-valuemin')).to.equal('0');
expect(base.getAttribute('aria-valuemax')).to.equal('5');
expect(base.getAttribute('tabindex')).to.equal('0');
expect(base.getAttribute('class')).to.equal(' rating ');
});
it('should be readonly with the readonly attribute', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" readonly></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
it('should be readonly with the readonly attribute', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" readonly></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
expect(base.getAttribute('aria-readonly')).to.equal('true');
expect(base.getAttribute('class')).to.equal(' rating rating--readonly ');
});
expect(base.getAttribute('aria-readonly')).to.equal('true');
expect(base.getAttribute('class')).to.equal(' rating rating--readonly ');
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" disabled></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" disabled></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
expect(base.getAttribute('aria-disabled')).to.equal('true');
expect(base.getAttribute('class')).to.equal(' rating rating--disabled ');
});
expect(base.getAttribute('aria-disabled')).to.equal('true');
expect(base.getAttribute('class')).to.equal(' rating rating--disabled ');
});
it('should set max value by attribute', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" max="12"></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
it('should set max value by attribute', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" max="12"></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
expect(base.getAttribute('aria-valuemax')).to.equal('12');
});
expect(base.getAttribute('aria-valuemax')).to.equal('12');
});
it('should set selected value by attribute', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" value="3"></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
it('should set selected value by attribute', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" value="3"></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
expect(base.getAttribute('aria-valuenow')).to.equal('3');
});
expect(base.getAttribute('aria-valuenow')).to.equal('3');
});
it('should emit wa-change when clicked', async () => {
const el = await fixture<WaRating>(html` <wa-rating></wa-rating> `);
const lastSymbol = el.shadowRoot!.querySelector<HTMLSpanElement>('.rating__symbol:last-child')!;
const changeHandler = sinon.spy();
it('should emit wa-change when clicked', async () => {
const el = await fixture<WaRating>(html` <wa-rating></wa-rating> `);
const lastSymbol = el.shadowRoot!.querySelector<HTMLSpanElement>('.rating__symbol:last-child')!;
const changeHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-change', changeHandler);
await clickOnElement(lastSymbol);
await el.updateComplete;
await clickOnElement(lastSymbol);
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(el.value).to.equal(5);
});
expect(changeHandler).to.have.been.calledOnce;
expect(el.value).to.equal(5);
});
it('should emit wa-change when the value is changed with the keyboard', async () => {
const el = await fixture<WaRating>(html` <wa-rating></wa-rating> `);
const changeHandler = sinon.spy();
it('should emit wa-change when the value is changed with the keyboard', async () => {
const el = await fixture<WaRating>(html` <wa-rating></wa-rating> `);
const changeHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.focus();
await el.updateComplete;
await sendKeys({ press: 'ArrowRight' });
await el.updateComplete;
el.addEventListener('wa-change', changeHandler);
el.focus();
await el.updateComplete;
await sendKeys({ press: 'ArrowRight' });
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(el.value).to.equal(1);
});
expect(changeHandler).to.have.been.calledOnce;
expect(el.value).to.equal(1);
});
it('should not emit wa-change when disabled', async () => {
const el = await fixture<WaRating>(html` <wa-rating value="5" disabled></wa-rating> `);
const lastSymbol = el.shadowRoot!.querySelector<HTMLSpanElement>('.rating__symbol:last-child')!;
const changeHandler = sinon.spy();
it('should not emit wa-change when disabled', async () => {
const el = await fixture<WaRating>(html` <wa-rating value="5" disabled></wa-rating> `);
const lastSymbol = el.shadowRoot!.querySelector<HTMLSpanElement>('.rating__symbol:last-child')!;
const changeHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-change', changeHandler);
await clickOnElement(lastSymbol);
await el.updateComplete;
await clickOnElement(lastSymbol);
await el.updateComplete;
expect(changeHandler).to.not.have.been.called;
expect(el.value).to.equal(5);
});
expect(changeHandler).to.not.have.been.called;
expect(el.value).to.equal(5);
});
it('should not emit wa-change when the value is changed programmatically', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" value="1"></wa-rating> `);
el.addEventListener('wa-change', () => expect.fail('wa-change incorrectly emitted'));
el.value = 5;
await el.updateComplete;
});
it('should not emit wa-change when the value is changed programmatically', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test" value="1"></wa-rating> `);
el.addEventListener('wa-change', () => expect.fail('wa-change incorrectly emitted'));
el.value = 5;
await el.updateComplete;
});
describe('focus', () => {
it('should focus inner div', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test"></wa-rating> `);
describe('focus', () => {
it('should focus inner div', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test"></wa-rating> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
el.focus();
await el.updateComplete;
el.focus();
await el.updateComplete;
expect(el.shadowRoot!.activeElement).to.equal(base);
expect(el.shadowRoot!.activeElement).to.equal(base);
});
});
describe('blur', () => {
it('should blur inner div', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test"></wa-rating> `);
el.focus();
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(el.shadowRoot!.activeElement).to.equal(null);
});
});
});
});
describe('blur', () => {
it('should blur inner div', async () => {
const el = await fixture<WaRating>(html` <wa-rating label="Test"></wa-rating> `);
el.focus();
await el.updateComplete;
el.blur();
await el.updateComplete;
expect(el.shadowRoot!.activeElement).to.equal(null);
});
});
}
});

View File

@@ -214,7 +214,7 @@ export default class WaRating extends WebAwesomeElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir;
const counter = Array.from(Array(this.max).keys());
let displayValue = 0;
@@ -272,7 +272,7 @@ export default class WaRating extends WebAwesomeElement {
: `inset(0 0 0 ${(displayValue - index) * 100}%)`
})}
>
${unsafeHTML(this.getSymbol(index + 1))}
${this.getSymbol(index + 1)}
</div>
<div
class="rating__partial--filled"

View File

@@ -1,5 +1,8 @@
import { expect, fixture, html } from '@open-wc/testing';
import { clientFixture } from '../../internal/test/fixture.js';
import { expect } from '@open-wc/testing';
import { html } from 'lit';
import sinon from 'sinon';
import type { hydratedFixture } from '../../internal/test/fixture.js';
import type WaRelativeTime from './relative-time.js';
interface WaRelativeTimeTestCase {
@@ -17,7 +20,10 @@ const expectFormattedRelativeTimeToBe = async (relativeTime: WaRelativeTime, exp
expect(textContent).to.equal(expectedOutput);
};
const createRelativeTimeWithDate = async (relativeDate: Date): Promise<WaRelativeTime> => {
const createRelativeTimeWithDate = async (
relativeDate: Date,
fixture: typeof hydratedFixture | typeof clientFixture
): Promise<WaRelativeTime> => {
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US"></wa-relative-time>
`);
@@ -70,116 +76,121 @@ const testCases: WaRelativeTimeTestCase[] = [
];
describe('wa-relative-time', () => {
it('should pass accessibility tests', async () => {
const relativeTime = await createRelativeTimeWithDate(currentTime);
// @TODO: figure out why hydratedFixture behaves differently from clientFixture
for (const fixture of [clientFixture]) {
describe(`with "${fixture.type}" rendering`, () => {
it('should pass accessibility tests', async () => {
const relativeTime = await createRelativeTimeWithDate(currentTime, fixture);
await expect(relativeTime).to.be.accessible();
});
describe('handles time correctly', () => {
let clock: sinon.SinonFakeTimers | null = null;
beforeEach(() => {
clock = sinon.useFakeTimers(currentTime);
});
afterEach(() => {
clock?.restore();
});
testCases.forEach(testCase => {
it(`shows the correct relative time given a Date object: ${testCase.expectedOutput}`, async () => {
const relativeTime = await createRelativeTimeWithDate(testCase.date);
await expectFormattedRelativeTimeToBe(relativeTime, testCase.expectedOutput);
await expect(relativeTime).to.be.accessible();
});
it(`shows the correct relative time given a String object: ${testCase.expectedOutput}`, async () => {
const dateString = testCase.date.toISOString();
describe('handles time correctly', () => {
let clock: sinon.SinonFakeTimers | null = null;
beforeEach(() => {
clock = sinon.useFakeTimers(currentTime);
});
afterEach(() => {
clock?.restore();
});
testCases.forEach(testCase => {
it(`shows the correct relative time given a Date object: ${testCase.expectedOutput}`, async () => {
const relativeTime = await createRelativeTimeWithDate(testCase.date, fixture);
await expectFormattedRelativeTimeToBe(relativeTime, testCase.expectedOutput);
});
it(`shows the correct relative time given a String object: ${testCase.expectedOutput}`, async () => {
const dateString = testCase.date.toISOString();
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" date="${dateString}"></wa-relative-time>
`);
await expectFormattedRelativeTimeToBe(relativeTime, testCase.expectedOutput);
});
});
it('always shows numeric if requested via numeric property', async () => {
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" numeric="always"></wa-relative-time>
`);
relativeTime.date = yesterday;
await expectFormattedRelativeTimeToBe(relativeTime, '1 day ago');
});
it('shows human readable form if appropriate and numeric property is auto', async () => {
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" numeric="auto"></wa-relative-time>
`);
relativeTime.date = yesterday;
await expectFormattedRelativeTimeToBe(relativeTime, 'yesterday');
});
it('shows the set date with the proper attributes at the time object', async () => {
const relativeTime = await createRelativeTimeWithDate(yesterday, fixture);
await relativeTime.updateComplete;
const timeElement = extractTimeElement(relativeTime);
expect(timeElement?.dateTime).to.equal(yesterday.toISOString());
});
it('allows to use a short form of the unit', async () => {
const twoYearsAgo = new Date(currentTime.getTime() - 2 * nonLeapYearInSeconds);
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" numeric="always" format="short"></wa-relative-time>
`);
relativeTime.date = twoYearsAgo;
await expectFormattedRelativeTimeToBe(relativeTime, '2 yr. ago');
});
it('allows to use a long form of the unit', async () => {
const twoYearsAgo = new Date(currentTime.getTime() - 2 * nonLeapYearInSeconds);
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" numeric="always" format="long"></wa-relative-time>
`);
relativeTime.date = twoYearsAgo;
await expectFormattedRelativeTimeToBe(relativeTime, '2 years ago');
});
it('is formatted according to the requested locale', async () => {
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="de-DE" numeric="auto"></wa-relative-time>
`);
relativeTime.date = yesterday;
await expectFormattedRelativeTimeToBe(relativeTime, 'gestern');
});
it('keeps the component in sync if requested', async () => {
const relativeTime = await createRelativeTimeWithDate(yesterday, fixture);
relativeTime.sync = true;
await expectFormattedRelativeTimeToBe(relativeTime, 'yesterday');
clock?.tick(dayInSeconds);
await expectFormattedRelativeTimeToBe(relativeTime, '2 days ago');
});
});
it('does not display a time element on invalid time string', async () => {
const invalidDateString = 'thisIsNotATimeString';
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" date="${dateString}"></wa-relative-time>
<wa-relative-time lang="en-US" date="${invalidDateString}"></wa-relative-time>
`);
await expectFormattedRelativeTimeToBe(relativeTime, testCase.expectedOutput);
await relativeTime.updateComplete;
expect(extractTimeElement(relativeTime)).to.be.null;
});
});
it('always shows numeric if requested via numeric property', async () => {
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" numeric="always"></wa-relative-time>
`);
relativeTime.date = yesterday;
await expectFormattedRelativeTimeToBe(relativeTime, '1 day ago');
});
it('shows human readable form if appropriate and numeric property is auto', async () => {
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" numeric="auto"></wa-relative-time>
`);
relativeTime.date = yesterday;
await expectFormattedRelativeTimeToBe(relativeTime, 'yesterday');
});
it('shows the set date with the proper attributes at the time object', async () => {
const relativeTime = await createRelativeTimeWithDate(yesterday);
await relativeTime.updateComplete;
const timeElement = extractTimeElement(relativeTime);
expect(timeElement?.dateTime).to.equal(yesterday.toISOString());
});
it('allows to use a short form of the unit', async () => {
const twoYearsAgo = new Date(currentTime.getTime() - 2 * nonLeapYearInSeconds);
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" numeric="always" format="short"></wa-relative-time>
`);
relativeTime.date = twoYearsAgo;
await expectFormattedRelativeTimeToBe(relativeTime, '2 yr. ago');
});
it('allows to use a long form of the unit', async () => {
const twoYearsAgo = new Date(currentTime.getTime() - 2 * nonLeapYearInSeconds);
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" numeric="always" format="long"></wa-relative-time>
`);
relativeTime.date = twoYearsAgo;
await expectFormattedRelativeTimeToBe(relativeTime, '2 years ago');
});
it('is formatted according to the requested locale', async () => {
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="de-DE" numeric="auto"></wa-relative-time>
`);
relativeTime.date = yesterday;
await expectFormattedRelativeTimeToBe(relativeTime, 'gestern');
});
it('keeps the component in sync if requested', async () => {
const relativeTime = await createRelativeTimeWithDate(yesterday);
relativeTime.sync = true;
await expectFormattedRelativeTimeToBe(relativeTime, 'yesterday');
clock?.tick(dayInSeconds);
await expectFormattedRelativeTimeToBe(relativeTime, '2 days ago');
});
});
it('does not display a time element on invalid time string', async () => {
const invalidDateString = 'thisIsNotATimeString';
const relativeTime: WaRelativeTime = await fixture<WaRelativeTime>(html`
<wa-relative-time lang="en-US" date="${invalidDateString}"></wa-relative-time>
`);
await relativeTime.updateComplete;
expect(extractTimeElement(relativeTime)).to.be.null;
});
}
});

View File

@@ -27,7 +27,7 @@ const availableUnits: UnitConfig[] = [
@customElement('wa-relative-time')
export default class WaRelativeTime extends WebAwesomeElement {
private readonly localize = new LocalizeController(this);
private updateTimeout: number;
private updateTimeout: number | ReturnType<typeof setTimeout>;
@state() private isoTime = '';
@state() private relativeTime = '';
@@ -96,7 +96,7 @@ export default class WaRelativeTime extends WebAwesomeElement {
nextInterval = getTimeUntilNextUnit('day'); // next day
}
this.updateTimeout = window.setTimeout(() => this.requestUpdate(), nextInterval);
this.updateTimeout = setTimeout(() => this.requestUpdate(), nextInterval);
}
return html` <time datetime=${this.isoTime} title=${this.relativeTime}>${this.relativeTime}</time> `;

View File

@@ -0,0 +1,19 @@
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
describe('<wa-resize-observer>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should be accessible', async () => {
const el = await fixture(
html`<wa-resize-observer>
<div>Resize this box and watch the console 👉</div>
</wa-resize-observer>`
);
await expect(el).to.be.accessible();
});
});
}
});

View File

@@ -34,7 +34,9 @@ export default class WaResizeObserver extends WebAwesomeElement {
});
if (!this.disabled) {
this.startObserver();
this.updateComplete.then(() => {
this.startObserver();
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ import { animateWithClass } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { html, isServer } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { RequiredValidator } from '../../internal/validators/required-validator.js';
import { scrollIntoView } from '../../internal/scroll.js';
@@ -89,12 +89,14 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
static styles: CSSResultGroup = [componentStyles, formControlStyles, styles];
static get validators() {
return [
...super.validators,
RequiredValidator({
validationElement: Object.assign(document.createElement('select'), { required: true })
})
];
const validators = isServer
? []
: [
RequiredValidator({
validationElement: Object.assign(document.createElement('select'), { required: true })
})
];
return [...super.validators, ...validators];
}
assumeInteractionOn = ['wa-blur', 'wa-input'];
@@ -124,14 +126,6 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
/** The name of the select, submitted as a name/value pair with form data. */
@property() name = '';
/**
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
* value attribute will be a space-delimited list of values based on the options selected, and the value property will
* be an array. **For this reason, values must not contain spaces.**
*/
@property({ attribute: false })
value: string | string[] = '';
private _defaultValue: string | string[] = '';
@property({
@@ -142,23 +136,55 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
toAttribute: (value: string | string[]) => (Array.isArray(value) ? value.join(' ') : value)
}
})
// @ts-expect-error defaultValue () is a property on the host, but is being used a getter / setter here.
set defaultValue(val: string | string[]) {
this._defaultValue = this.convertDefaultValue(val);
}
get defaultValue() {
if (!this.hasUpdated) {
this._defaultValue = this.convertDefaultValue(this._defaultValue);
}
return this._defaultValue;
}
/**
* @private
* A converter for defaultValue from array to string if its multiple. Also fixes some hydration issues.
*/
private convertDefaultValue(val: typeof this.defaultValue) {
// For some reason this can go off before we've fully updated. So check the attribute too.
const isMultiple = this.multiple || this.hasAttribute('multiple');
if (!isMultiple && Array.isArray(val)) {
val = val.join(' ');
}
this._defaultValue = val;
if (!this.hasInteracted) {
this.value = this.defaultValue;
}
return val;
}
get defaultValue() {
return this._defaultValue;
private _value: string | string[] | null = this.defaultValue;
/**
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
* value attribute will be a space-delimited list of values based on the options selected, and the value property will
* be an array. **For this reason, values must not contain spaces.**
*/
get value() {
if (this.valueHasChanged) {
return this._value;
}
return this._value ?? this.defaultValue;
}
@property({ attribute: false })
set value(val: string | string[] | null) {
if (this._value === val) {
return;
}
this.valueHasChanged = true;
this._value = val;
}
/** The select's size. */
@@ -212,6 +238,16 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
/** The select's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/**
* Used for SSR purposes when a label is slotted in. Will show the label on first render.
*/
@property({ attribute: 'with-label', type: Boolean }) withLabel = false;
/**
* Used for SSR purposes when help-text is slotted in. Will show the help-text on first render.
*/
@property({ attribute: 'with-help-text', type: Boolean }) withHelpText = false;
/**
* By default, form controls are associated with the nearest containing `<form>` element. This attribute allows you
* to place the form control outside of a form and associate it with the form that has this `id`. The form must be in
@@ -636,7 +672,7 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
}
} else {
this.value = this.selectedOptions[0]?.value ?? '';
this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? '';
this.displayLabel = this.selectedOptions[0]?.getTextLabel?.() ?? '';
}
// Update validity
@@ -767,12 +803,13 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasLabelSlot = this.hasUpdated ? this.hasSlotController.test('label') : this.withLabel;
const hasHelpTextSlot = this.hasUpdated ? this.hasSlotController.test('help-text') : this.withHelpText;
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
const isPlaceholderVisible = this.placeholder && this.value.length === 0;
const hasClearIcon =
(this.hasUpdated || isServer) && this.clearable && !this.disabled && this.value && this.value.length > 0;
const isPlaceholderVisible = Boolean(this.placeholder && (!this.value || this.value.length === 0));
return html`
<div

View File

@@ -1,32 +1,38 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaSkeleton from './skeleton.js';
describe('<wa-skeleton>', () => {
it('should render default skeleton', async () => {
const el = await fixture<WaSkeleton>(html` <wa-skeleton></wa-skeleton> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render default skeleton', async () => {
const el = await fixture<WaSkeleton>(html` <wa-skeleton></wa-skeleton> `);
await expect(el).to.be.accessible();
await expect(el).to.be.accessible();
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const indicator = el.shadowRoot!.querySelector<HTMLElement>('[part~="indicator"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const indicator = el.shadowRoot!.querySelector<HTMLElement>('[part~="indicator"]')!;
expect(base.getAttribute('class')).to.equal(' skeleton ');
expect(indicator.getAttribute('class')).to.equal('skeleton__indicator');
});
expect(base.getAttribute('class')).to.equal(' skeleton ');
expect(indicator.getAttribute('class')).to.equal('skeleton__indicator');
});
it('should set pulse effect by attribute', async () => {
const el = await fixture<WaSkeleton>(html` <wa-skeleton effect="pulse"></wa-skeleton> `);
it('should set pulse effect by attribute', async () => {
const el = await fixture<WaSkeleton>(html` <wa-skeleton effect="pulse"></wa-skeleton> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
expect(base.getAttribute('class')).to.equal(' skeleton skeleton--pulse ');
});
expect(base.getAttribute('class')).to.equal(' skeleton skeleton--pulse ');
});
it('should set sheen effect by attribute', async () => {
const el = await fixture<WaSkeleton>(html` <wa-skeleton effect="sheen"></wa-skeleton> `);
it('should set sheen effect by attribute', async () => {
const el = await fixture<WaSkeleton>(html` <wa-skeleton effect="sheen"></wa-skeleton> `);
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part~="base"]')!;
expect(base.getAttribute('class')).to.equal(' skeleton skeleton--sheen ');
});
expect(base.getAttribute('class')).to.equal(' skeleton skeleton--sheen ');
});
});
}
});

View File

@@ -1,24 +1,30 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaSpinner from './spinner.js';
describe('<wa-spinner>', () => {
describe('when provided no parameters', () => {
it('should pass accessibility tests', async () => {
const spinner = await fixture<WaSpinner>(html` <wa-spinner></wa-spinner> `);
await expect(spinner).to.be.accessible();
});
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('when provided no parameters', () => {
it('should pass accessibility tests', async () => {
const spinner = await fixture<WaSpinner>(html` <wa-spinner></wa-spinner> `);
await expect(spinner).to.be.accessible();
});
it('should have a role of "status".', async () => {
const spinner = await fixture<WaSpinner>(html` <wa-spinner></wa-spinner> `);
const base = spinner.shadowRoot!.querySelector('[part~="base"]')!;
expect(base).have.attribute('role', 'progressbar');
});
it('should have a role of "status".', async () => {
const spinner = await fixture<WaSpinner>(html` <wa-spinner></wa-spinner> `);
const base = spinner.shadowRoot!.querySelector('[part~="base"]')!;
expect(base).have.attribute('role', 'progressbar');
});
it('should have flex:none to prevent flex re-sizing', async () => {
const spinner = await fixture<WaSpinner>(html` <wa-spinner></wa-spinner> `);
it('should have flex:none to prevent flex re-sizing', async () => {
const spinner = await fixture<WaSpinner>(html` <wa-spinner></wa-spinner> `);
// 0 0 auto is a compiled value for `none`
expect(getComputedStyle(spinner).flex).to.equal('0 0 auto');
// 0 0 auto is a compiled value for `none`
expect(getComputedStyle(spinner).flex).to.equal('0 0 auto');
});
});
});
});
}
});

View File

@@ -1,5 +1,7 @@
import { dragElement } from '../../internal/test.js';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { expect, oneEvent } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { queryByTestId } from '../../internal/test/data-testid-helpers.js';
import { resetMouse } from '@web/test-runner-commands';
import type WaSplitPanel from './split-panel.js';
@@ -36,258 +38,262 @@ describe('<wa-split-panel>', () => {
await resetMouse().catch(() => {});
});
it('should render a component', async () => {
const splitPanel = await fixture(html` <wa-split-panel></wa-split-panel> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render a component', async () => {
const splitPanel = await fixture(html` <wa-split-panel></wa-split-panel> `);
expect(splitPanel).to.exist;
});
expect(splitPanel).to.exist;
});
it('should be accessible', async () => {
const splitPanel = await fixture(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
it('should be accessible', async () => {
const splitPanel = await fixture(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
await expect(splitPanel).to.be.accessible();
});
await expect(splitPanel).to.be.accessible();
});
it('should show both panels', async () => {
const splitPanel = await fixture(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
it('should show both panels', async () => {
const splitPanel = await fixture(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
expect(splitPanel).to.contain.text('Start');
expect(splitPanel).to.contain.text('End');
});
expect(splitPanel).to.contain.text('Start');
expect(splitPanel).to.contain.text('End');
});
describe('panel sizing horizontal', () => {
it('has two evenly sized panels by default', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel>
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
describe('panel sizing horizontal', () => {
it('has two evenly sized panels by default', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel>
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
const endPanelWidth = getPanelWidth(splitPanel, 'end-panel');
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
const endPanelWidth = getPanelWidth(splitPanel, 'end-panel');
expect(startPanelWidth).to.be.equal(endPanelWidth);
expect(startPanelWidth).to.be.equal(endPanelWidth);
});
it('changes the sizing of the panels based on the position attribute', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel position="25">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
const endPanelWidth = getPanelWidth(splitPanel, 'end-panel');
expect(startPanelWidth * 3).to.be.equal(endPanelWidth - DIVIDER_WIDTH_IN_PX);
});
it('updates the position in pixels to the correct result', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel position="25">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
splitPanel.position = 10;
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
expect(startPanelWidth).to.be.equal(splitPanel.positionInPixels - DIVIDER_WIDTH_IN_PX / 2);
});
it('emits the wa-reposition event on position change', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const repositionPromise = oneEvent(splitPanel, 'wa-reposition');
splitPanel.position = 10;
return repositionPromise;
});
it('can be resized using the mouse', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 30);
});
it('cannot be resized if disabled', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel disabled>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels);
});
it('snaps to predefined positions', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
splitPanel.snap = `${positionInPixels - 40}px`;
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 40);
});
});
describe('panel sizing vertical', () => {
it('has two evenly sized panels by default', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
const endPanelHeight = getPanelHeight(splitPanel, 'end-panel');
expect(startPanelHeight).to.be.equal(endPanelHeight);
});
it('changes the sizing of the panels based on the position attribute', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel position="25" vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
const endPanelHeight = getPanelHeight(splitPanel, 'end-panel');
expect(startPanelHeight * 3).to.be.equal(endPanelHeight - DIVIDER_WIDTH_IN_PX);
});
it('updates the position in pixels to the correct result', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel position="25" vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
splitPanel.position = 10;
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
expect(startPanelHeight).to.be.equal(splitPanel.positionInPixels - DIVIDER_WIDTH_IN_PX / 2);
});
it('emits the wa-reposition event on position change ', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const repositionPromise = oneEvent(splitPanel, 'wa-reposition');
splitPanel.position = 10;
return repositionPromise;
});
it('can be resized using the mouse ', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 30);
});
it('cannot be resized if disabled', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel disabled vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels);
});
it('snaps to predefined positions', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
splitPanel.snap = `${positionInPixels - 40}px`;
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 40);
});
});
});
it('changes the sizing of the panels based on the position attribute', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel position="25">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
const endPanelWidth = getPanelWidth(splitPanel, 'end-panel');
expect(startPanelWidth * 3).to.be.equal(endPanelWidth - DIVIDER_WIDTH_IN_PX);
});
it('updates the position in pixels to the correct result', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel position="25">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
splitPanel.position = 10;
const startPanelWidth = getPanelWidth(splitPanel, 'start-panel');
expect(startPanelWidth).to.be.equal(splitPanel.positionInPixels - DIVIDER_WIDTH_IN_PX / 2);
});
it('emits the wa-reposition event on position change', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const repositionPromise = oneEvent(splitPanel, 'wa-reposition');
splitPanel.position = 10;
return repositionPromise;
});
it('can be resized using the mouse', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 30);
});
it('cannot be resized if disabled', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel disabled>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels);
});
it('snaps to predefined positions', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel>
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
splitPanel.snap = `${positionInPixels - 40}px`;
const divider = getDivider(splitPanel);
await dragElement(divider, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 40);
});
});
describe('panel sizing vertical', () => {
it('has two evenly sized panels by default', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
const endPanelHeight = getPanelHeight(splitPanel, 'end-panel');
expect(startPanelHeight).to.be.equal(endPanelHeight);
});
it('changes the sizing of the panels based on the position attribute', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel position="25" vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
const endPanelHeight = getPanelHeight(splitPanel, 'end-panel');
expect(startPanelHeight * 3).to.be.equal(endPanelHeight - DIVIDER_WIDTH_IN_PX);
});
it('updates the position in pixels to the correct result', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel position="25" vertical style="height: 400px;">
<div slot="start" data-testid="start-panel">Start</div>
<div slot="end" data-testid="end-panel">End</div>
</wa-split-panel>`
);
splitPanel.position = 10;
const startPanelHeight = getPanelHeight(splitPanel, 'start-panel');
expect(startPanelHeight).to.be.equal(splitPanel.positionInPixels - DIVIDER_WIDTH_IN_PX / 2);
});
it('emits the wa-reposition event on position change ', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const repositionPromise = oneEvent(splitPanel, 'wa-reposition');
splitPanel.position = 10;
return repositionPromise;
});
it('can be resized using the mouse ', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 30);
});
it('cannot be resized if disabled', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel disabled vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels);
});
it('snaps to predefined positions', async () => {
const splitPanel = await fixture<WaSplitPanel>(
html`<wa-split-panel vertical style="height: 400px;">
<div slot="start">Start</div>
<div slot="end">End</div>
</wa-split-panel>`
);
const positionInPixels = Math.round(splitPanel.positionInPixels);
splitPanel.snap = `${positionInPixels - 40}px`;
const divider = getDivider(splitPanel);
await dragElement(divider, 0, -30);
const positionInPixelsAfterDrag = Math.round(splitPanel.positionInPixels);
expect(positionInPixelsAfterDrag).to.be.equal(positionInPixels - 40);
});
});
}
});

View File

@@ -105,7 +105,7 @@ export default class WaSplitPanel extends WebAwesomeElement {
}
private handleDrag(event: PointerEvent) {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
if (this.disabled) {
return;
@@ -226,7 +226,7 @@ export default class WaSplitPanel extends WebAwesomeElement {
render() {
const gridTemplate = this.vertical ? 'gridTemplateRows' : 'gridTemplateColumns';
const gridTemplateAlt = this.vertical ? 'gridTemplateColumns' : 'gridTemplateRows';
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
const primary = `
clamp(
0%,
@@ -240,6 +240,12 @@ export default class WaSplitPanel extends WebAwesomeElement {
`;
const secondary = 'auto';
// @TODO: Create an actual fix for this. [Konnor]
if (!this.style) {
// @ts-expect-error `this.style` doesn't exist on the server.
this.style = {};
}
if (this.primary === 'end') {
if (isRtl && !this.vertical) {
this.style[gridTemplate] = `${primary} var(--divider-width) ${secondary}`;

View File

@@ -1,327 +1,333 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { aTimeout, expect, oneEvent, waitUntil } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { runFormControlBaseTests } from '../../internal/test/form-control-base-tests.js';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type WaSwitch from './switch.js';
describe('<wa-switch>', async () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch>Switch</wa-switch> `);
await expect(el).to.be.accessible();
});
describe('<wa-switch>', () => {
runFormControlBaseTests('wa-switch');
it('default properties', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
expect(el.name).to.equal(null);
expect(el.value).to.be.null;
expect(el.title).to.equal('');
expect(el.disabled).to.be.false;
expect(el.required).to.be.false;
expect(el.checked).to.be.false;
expect(el.defaultChecked).to.be.false;
expect(el.helpText).to.equal('');
});
it('should have title if title attribute is set', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch title="Test"></wa-switch> `);
const input = el.shadowRoot!.querySelector('input')!;
expect(input.title).to.equal('Test');
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch disabled></wa-switch> `);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
expect(input.disabled).to.be.true;
});
it('should be valid by default', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
expect(el.checkValidity()).to.be.true;
});
it('should emit wa-change and wa-input when clicked', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.click();
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should emit wa-change when toggled with spacebar', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: ' ' });
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should emit wa-change and wa-input when toggled with the right arrow', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: 'ArrowRight' });
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should emit wa-change and wa-input when toggled with the left arrow', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch checked></wa-switch> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: 'ArrowLeft' });
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.false;
});
it('should not emit wa-change or wa-input when checked is set by JavaScript', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
el.addEventListener('wa-change', () => expect.fail('wa-change incorrectly emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-change incorrectly emitted'));
el.checked = true;
await el.updateComplete;
el.checked = false;
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<WaSwitch>(html` <wa-switch></wa-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`
<form>
<wa-switch name="a" value="1" checked></wa-switch>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch>Switch</wa-switch> `);
await expect(el).to.be.accessible();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
it('default properties', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('1');
});
it('should submit "on" when no value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-switch name="a" checked></wa-switch>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
expect(el.name).to.equal(null);
expect(el.value).to.be.null;
expect(el.title).to.equal('');
expect(el.disabled).to.be.false;
expect(el.required).to.be.false;
expect(el.checked).to.be.false;
expect(el.defaultChecked).to.be.false;
expect(el.helpText).to.equal('');
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
it('should have title if title attribute is set', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch title="Test"></wa-switch> `);
const input = el.shadowRoot!.querySelector('input')!;
await waitUntil(() => submitHandler.calledOnce);
expect(input.title).to.equal('Test');
});
expect(formData!.get('a')).to.equal('on');
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch disabled></wa-switch> `);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
expect(input.disabled).to.be.true;
});
it('should be valid by default', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
expect(el.checkValidity()).to.be.true;
});
it('should emit wa-change and wa-input when clicked', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.click();
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should emit wa-change when toggled with spacebar', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: ' ' });
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should emit wa-change and wa-input when toggled with the right arrow', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: 'ArrowRight' });
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.true;
});
it('should emit wa-change and wa-input when toggled with the left arrow', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch checked></wa-switch> `);
const changeHandler = sinon.spy();
const inputHandler = sinon.spy();
el.addEventListener('wa-change', changeHandler);
el.addEventListener('wa-input', inputHandler);
el.focus();
await sendKeys({ press: 'ArrowLeft' });
await el.updateComplete;
expect(changeHandler).to.have.been.calledOnce;
expect(inputHandler).to.have.been.calledOnce;
expect(el.checked).to.be.false;
});
it('should not emit wa-change or wa-input when checked is set by JavaScript', async () => {
const el = await fixture<WaSwitch>(html` <wa-switch></wa-switch> `);
el.addEventListener('wa-change', () => expect.fail('wa-change incorrectly emitted'));
el.addEventListener('wa-input', () => expect.fail('wa-change incorrectly emitted'));
el.checked = true;
await el.updateComplete;
el.checked = false;
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<WaSwitch>(html` <wa-switch></wa-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`
<form>
<wa-switch name="a" value="1" checked></wa-switch>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('1');
});
it('should submit "on" when no value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-switch name="a" checked></wa-switch>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('on');
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-switch name="a" value="1" checked></wa-switch>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const waSwitch = form.querySelector('wa-switch')!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
// Submitting the form after setting custom validity should not trigger the handler
waSwitch.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
await aTimeout(100);
expect(submitHandler).to.not.have.been.called;
});
it('should be invalid when required and unchecked', async () => {
const waSwitch = await fixture<HTMLFormElement>(html` <wa-switch required></wa-switch> `);
expect(waSwitch.checkValidity()).to.be.false;
});
it('should be valid when required and checked', async () => {
const waSwitch = await fixture<HTMLFormElement>(html` <wa-switch required checked></wa-switch> `);
expect(waSwitch.checkValidity()).to.be.true;
});
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>
<form id="f">
<wa-button type="submit">Submit</wa-button>
</form>
<wa-switch form="f" name="a" value="1" checked></wa-switch>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
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><wa-switch required></wa-switch></form> `);
const waSwitch = el.querySelector<WaSwitch>('wa-switch')!;
expect(waSwitch.hasAttribute('data-wa-required')).to.be.true;
expect(waSwitch.hasAttribute('data-wa-optional')).to.be.false;
expect(waSwitch.hasAttribute('data-wa-invalid')).to.be.true;
expect(waSwitch.hasAttribute('data-wa-valid')).to.be.false;
expect(waSwitch.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(waSwitch.hasAttribute('data-wa-user-valid')).to.be.false;
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-switch name="a" value="1" checked></wa-switch>
<wa-button type="reset">Reset</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const switchEl = form.querySelector('wa-switch')!;
switchEl.checked = false;
await switchEl.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await switchEl.updateComplete;
expect(switchEl.checked).to.true;
switchEl.defaultChecked = false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await switchEl.updateComplete;
expect(switchEl.checked).to.false;
});
});
it('should not jump the page to the bottom when focusing a switch at the bottom of an element with overflow: auto;', async () => {
// https://github.com/shoelace-style/shoelace/issues/1169
const el = await fixture<HTMLDivElement>(html`
<div style="display: flex; flex-direction: column; overflow: auto; max-height: 400px;">
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
</div>
;
`);
const switches = el.querySelectorAll<WaSwitch>('wa-switch');
const lastSwitch = switches[switches.length - 1];
expect(window.scrollY).to.equal(0);
// Without these 2 timeouts, tests will pass unexpectedly in Safari.
await aTimeout(10);
lastSwitch.focus();
await aTimeout(10);
expect(window.scrollY).to.equal(0);
});
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-switch name="a" value="1" checked></wa-switch>
<wa-button type="submit">Submit</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const waSwitch = form.querySelector('wa-switch')!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
// Submitting the form after setting custom validity should not trigger the handler
waSwitch.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
await aTimeout(100);
expect(submitHandler).to.not.have.been.called;
});
it('should be invalid when required and unchecked', async () => {
const waSwitch = await fixture<HTMLFormElement>(html` <wa-switch required></wa-switch> `);
expect(waSwitch.checkValidity()).to.be.false;
});
it('should be valid when required and checked', async () => {
const waSwitch = await fixture<HTMLFormElement>(html` <wa-switch required checked></wa-switch> `);
expect(waSwitch.checkValidity()).to.be.true;
});
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>
<form id="f">
<wa-button type="submit">Submit</wa-button>
</form>
<wa-switch form="f" name="a" value="1" checked></wa-switch>
</div>
`);
const form = el.querySelector('form')!;
const formData = new FormData(form);
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><wa-switch required></wa-switch></form> `);
const waSwitch = el.querySelector<WaSwitch>('wa-switch')!;
expect(waSwitch.hasAttribute('data-wa-required')).to.be.true;
expect(waSwitch.hasAttribute('data-wa-optional')).to.be.false;
expect(waSwitch.hasAttribute('data-wa-invalid')).to.be.true;
expect(waSwitch.hasAttribute('data-wa-valid')).to.be.false;
expect(waSwitch.hasAttribute('data-wa-user-invalid')).to.be.false;
expect(waSwitch.hasAttribute('data-wa-user-valid')).to.be.false;
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<wa-switch name="a" value="1" checked></wa-switch>
<wa-button type="reset">Reset</wa-button>
</form>
`);
const button = form.querySelector('wa-button')!;
const switchEl = form.querySelector('wa-switch')!;
switchEl.checked = false;
await switchEl.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await switchEl.updateComplete;
expect(switchEl.checked).to.true;
switchEl.defaultChecked = false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await switchEl.updateComplete;
expect(switchEl.checked).to.false;
});
});
it('should not jump the page to the bottom when focusing a switch at the bottom of an element with overflow: auto;', async () => {
// https://github.com/shoelace-style/shoelace/issues/1169
const el = await fixture<HTMLDivElement>(html`
<div style="display: flex; flex-direction: column; overflow: auto; max-height: 400px;">
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
<wa-switch>Switch</wa-switch>
</div>
;
`);
const switches = el.querySelectorAll<WaSwitch>('wa-switch');
const lastSwitch = switches[switches.length - 1];
expect(window.scrollY).to.equal(0);
// Without these 2 timeouts, tests will pass unexpectedly in Safari.
await aTimeout(10);
lastSwitch.focus();
await aTimeout(10);
expect(window.scrollY).to.equal(0);
});
await runFormControlBaseTests('wa-switch');
}
});

View File

@@ -69,8 +69,29 @@ export default class WaSwitch extends WebAwesomeFormAssociatedElement {
/** The name of the switch, submitted as a name/value pair with form data. */
@property({ reflect: true }) name: string | null = null;
private _value: string | null = null;
/** The current value of the switch, submitted as a name/value pair with form data. */
@property() value: null | string;
get value() {
if (this.valueHasChanged) {
return this._value;
}
return this._value ?? this.defaultValue;
}
@state()
set value(val: string | null) {
if (this._value === val) {
return;
}
this.valueHasChanged = true;
this._value = val;
}
/** The default value of the form control. Primarily used for resetting the form control. */
@property({ attribute: 'value', reflect: true }) defaultValue: null | string = this.getAttribute('value') || null;
/** The switch's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@@ -97,6 +118,11 @@ export default class WaSwitch extends WebAwesomeFormAssociatedElement {
/** The switch's help text. If you need to display HTML, use the `help-text` slot instead. */
@property({ attribute: 'help-text' }) helpText = '';
/**
* Used for SSR. If you slot in help-text, make sure to add `with-help-text` to your component to get it to properly render with SSR.
*/
@property({ attribute: 'with-help-text', type: Boolean }) withHelpText = false;
firstUpdated(changedProperties: PropertyValues<typeof this>) {
super.firstUpdated(changedProperties);
@@ -113,6 +139,7 @@ export default class WaSwitch extends WebAwesomeFormAssociatedElement {
}
private handleClick() {
this.hasInteracted = true;
this.checked = !this.checked;
this.dispatchEvent(new WaChangeEvent());
}
@@ -138,16 +165,26 @@ export default class WaSwitch extends WebAwesomeFormAssociatedElement {
}
}
@watch(['value', 'checked'], { waitUntilFirstUpdate: true })
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has('defaultChecked') || changedProperties.has('value') || changedProperties.has('checked')) {
this.handleValueOrCheckedChange();
}
}
handleValueOrCheckedChange() {
this.handleDefaultCheckedChange();
this.value = this.checked ? this.value || 'on' : null;
this.input.checked = this.checked; // force a sync update
// These @watch() commands seem to override the base element checks for changes, so we need to setValue for the form and and updateValidity()
if (this.input) {
this.input.checked = this.checked; // force a sync update
}
this.setValue(this.value, this.value);
this.updateValidity();
}
@watch('defaultChecked')
handleDefaultCheckedChange() {
if (!this.hasInteracted && this.checked !== this.defaultChecked) {
this.checked = this.defaultChecked;
@@ -197,7 +234,7 @@ export default class WaSwitch extends WebAwesomeFormAssociatedElement {
}
render() {
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasHelpTextSlot = this.hasUpdated ? this.hasSlotController.test('help-text') : this.withHelpText;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
return html`

View File

@@ -1,5 +1,6 @@
import { aTimeout, elementUpdated, expect, fixture, oneEvent, waitUntil } from '@open-wc/testing';
import { aTimeout, elementUpdated, expect, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test.js';
import { clientFixture, fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import { isElementVisibleFromOverflow } from '../../internal/test/element-visible-overflow.js';
import { queryByTestId } from '../../internal/test/data-testid-helpers.js';
@@ -72,380 +73,399 @@ const waitForHeaderToBeActive = async (container: HTMLElement, headerTestId: str
};
describe('<wa-tab-group>', () => {
it('renders', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-tab-group>
`);
expect(tabGroup).to.be.visible;
});
it('is accessible', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-tab-group>
`);
await expect(tabGroup).to.be.accessible();
});
it('displays all tabs', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-tab-header">General</wa-tab>
<wa-tab slot="nav" panel="disabled" disabled data-testid="disabled-tab-header">Disabled</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="disabled">This is a disabled tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab slot="nav" panel="custom">Custom</wa-tab>
<wa-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom">This is the custom tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-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`<wa-tab slot="nav" panel="tab-${i}">Tab ${i}</wa-tab>
<wa-tab-panel name="tab-${i}">Content of tab ${i}0</wa-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<WaTabGroup>(html`<wa-tab-group> ${generateTabs(30)} </wa-tab-group>`);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-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<WaTabGroup>(html`<wa-tab-group> ${generateTabs(30)} </wa-tab-group>`);
tabGroup.noScrollControls = true;
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-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<WaTabGroup>(html`<wa-tab-group> ${generateTabs(2)} </wa-tab-group>`);
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
// TODO - this fails sporadically, likely due to a timing issue. It tests fine manually.
it.skip('does not show scroll buttons if placement is start', async () => {
const tabGroup = await fixture<WaTabGroup>(html`<wa-tab-group> ${generateTabs(50)} </wa-tab-group>`);
tabGroup.placement = 'start';
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
// TODO - this fails sporadically, likely due to a timing issue. It tests fine manually.
it.skip('does not show scroll buttons if placement is end', async () => {
const tabGroup = await fixture<WaTabGroup>(html`<wa-tab-group> ${generateTabs(50)} </wa-tab-group>`);
tabGroup.placement = 'end';
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
// TODO - this fails sporadically, likely due to a timing issue. It tests fine manually.
it.skip('does scroll on scroll button click', async () => {
const numberOfElements = 15;
const tabGroup = await fixture<WaTabGroup>(
html`<wa-tab-group> ${generateTabs(numberOfElements)} </wa-tab-group>`
);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-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('wa-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: WaTabGroup, action: () => Promise<void>): Promise<void> => {
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
const customHeader = queryByTestId<WaTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'wa-tab-show') as Promise<WaTabShowEvent>;
await action();
expect(customHeader).to.have.attribute('active');
await expectPromiseToHaveName(showEventPromise, 'custom');
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
};
const expectGeneralTabToBeStillActiveAfter = async (
tabGroup: WaTabGroup,
action: () => Promise<void>
): Promise<void> => {
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
let showEventFired = false;
let hideEventFired = false;
oneEvent(tabGroup, 'wa-tab-show').then(() => (showEventFired = true));
oneEvent(tabGroup, 'wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
const customHeader = queryByTestId<WaTab>(tabGroup, 'custom-header');
return expectCustomTabToBeActiveAfter(tabGroup, () => clickOnElement(customHeader!));
});
it('does not change if the active tab is reselected', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom">Custom</wa-tab>
<wa-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom">This is the custom tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="disabled" data-testid="disabled-header" disabled>disabled</wa-tab>
<wa-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="disabled">This is the disabled tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
tabGroup.activation = 'manual';
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
const customHeader = queryByTestId<WaTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'wa-tab-show') as Promise<WaTabShowEvent>;
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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="disabled" disabled>Disabled</wa-tab>
<wa-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="disabled">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => sendKeys({ press: 'ArrowRight' }));
});
it('selects a tab by using the show function', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
return expectCustomTabToBeActiveAfter(tabGroup, () => {
tabGroup.active = 'custom';
return aTimeout(0);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('renders', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-tab-group>
`);
expect(tabGroup).to.be.visible;
});
it('is accessible', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-tab-group>
`);
await expect(tabGroup).to.be.accessible();
});
it('displays all tabs', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-tab-header">General</wa-tab>
<wa-tab slot="nav" panel="disabled" disabled data-testid="disabled-tab-header">Disabled</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="disabled">This is a disabled tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab slot="nav" panel="custom">Custom</wa-tab>
<wa-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom">This is the custom tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general">General</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
</wa-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`<wa-tab slot="nav" panel="tab-${i}">Tab ${i}</wa-tab>
<wa-tab-panel name="tab-${i}">Content of tab ${i}0</wa-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 () => {
// @TODO: Investigate why this fails with hydratedFixture (generateTabs()) [Konnor]
// https://github.com/lit/lit/issues/4739
const tabGroup = await clientFixture<WaTabGroup>(html`<wa-tab-group> ${generateTabs(30)} </wa-tab-group>`);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-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 () => {
// @TODO: Investigate why this fails with hydratedFixture (generateTabs()) [Konnor]
// https://github.com/lit/lit/issues/4739
const tabGroup = await clientFixture<WaTabGroup>(html`<wa-tab-group> ${generateTabs(30)} </wa-tab-group>`);
tabGroup.noScrollControls = true;
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does not show scroll buttons if all tabs fit on the screen', async () => {
// @TODO: Investigate why this fails with hydratedFixture (generateTabs()) [Konnor]
// https://github.com/lit/lit/issues/4739
const tabGroup = await clientFixture<WaTabGroup>(html`<wa-tab-group> ${generateTabs(2)} </wa-tab-group>`);
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
// TODO - this fails sporadically, likely due to a timing issue. It tests fine manually.
it.skip('does not show scroll buttons if placement is start', async () => {
const tabGroup = await fixture<WaTabGroup>(html`<wa-tab-group> ${generateTabs(50)} </wa-tab-group>`);
tabGroup.placement = 'start';
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
// TODO - this fails sporadically, likely due to a timing issue. It tests fine manually.
it.skip('does not show scroll buttons if placement is end', async () => {
const tabGroup = await fixture<WaTabGroup>(html`<wa-tab-group> ${generateTabs(50)} </wa-tab-group>`);
tabGroup.placement = 'end';
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
// TODO - this fails sporadically, likely due to a timing issue. It tests fine manually.
it.skip('does scroll on scroll button click', async () => {
const numberOfElements = 15;
const tabGroup = await fixture<WaTabGroup>(
html`<wa-tab-group> ${generateTabs(numberOfElements)} </wa-tab-group>`
);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-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('wa-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: WaTabGroup,
action: () => Promise<void>
): Promise<void> => {
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
const customHeader = queryByTestId<WaTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'wa-tab-show') as Promise<WaTabShowEvent>;
await action();
expect(customHeader).to.have.attribute('active');
await expectPromiseToHaveName(showEventPromise, 'custom');
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
};
const expectGeneralTabToBeStillActiveAfter = async (
tabGroup: WaTabGroup,
action: () => Promise<void>
): Promise<void> => {
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
let showEventFired = false;
let hideEventFired = false;
oneEvent(tabGroup, 'wa-tab-show').then(() => (showEventFired = true));
oneEvent(tabGroup, 'wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
const customHeader = queryByTestId<WaTab>(tabGroup, 'custom-header');
return expectCustomTabToBeActiveAfter(tabGroup, () => clickOnElement(customHeader!));
});
it('does not change if the active tab is reselected', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom">Custom</wa-tab>
<wa-tab-panel name="general" data-testid="general-tab-content"
>This is the general tab panel.</wa-tab-panel
>
<wa-tab-panel name="custom">This is the custom tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="disabled" data-testid="disabled-header" disabled>disabled</wa-tab>
<wa-tab-panel name="general" data-testid="general-tab-content"
>This is the general tab panel.</wa-tab-panel
>
<wa-tab-panel name="disabled">This is the disabled tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
tabGroup.activation = 'manual';
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
const customHeader = queryByTestId<WaTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'wa-tab-show') as Promise<WaTabShowEvent>;
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<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="disabled" disabled>Disabled</wa-tab>
<wa-tab-panel name="general" data-testid="general-tab-content"
>This is the general tab panel.</wa-tab-panel
>
<wa-tab-panel name="disabled">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => sendKeys({ press: 'ArrowRight' }));
});
it('selects a tab by using the show function', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
return expectCustomTabToBeActiveAfter(tabGroup, () => {
tabGroup.active = 'custom';
return aTimeout(0);
});
});
});
});
});
}
});

View File

@@ -360,7 +360,7 @@ export default class WaTabGroup extends WebAwesomeElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
return html`
<div

View File

@@ -1,43 +1,49 @@
import { aTimeout, expect, fixture, html } from '@open-wc/testing';
import { aTimeout, expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaTabPanel from './tab-panel.js';
describe('<wa-tab-panel>', () => {
it('passes accessibility test', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel>Test</wa-tab-panel> `);
await expect(el).to.be.accessible();
});
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('passes accessibility test', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel>Test</wa-tab-panel> `);
await expect(el).to.be.accessible();
});
it('default properties', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel>Test</wa-tab-panel> `);
it('default properties', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel>Test</wa-tab-panel> `);
expect(el.id).to.equal('wa-tab-panel-2');
expect(el.name).to.equal('');
expect(el.active).to.equal(false);
expect(el.getAttribute('role')).to.equal('tabpanel');
expect(el.getAttribute('aria-hidden')).to.equal('true');
});
expect(el.id).to.not.be.empty;
expect(el.name).to.equal('');
expect(el.active).to.equal(false);
expect(el.getAttribute('role')).to.equal('tabpanel');
expect(el.getAttribute('aria-hidden')).to.equal('true');
});
it('properties should reflect', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel>Test</wa-tab-panel> `);
it('properties should reflect', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel>Test</wa-tab-panel> `);
el.name = 'test';
el.active = true;
await aTimeout(100);
expect(el.getAttribute('name')).to.equal('test');
expect(el.hasAttribute('active')).to.equal(true);
});
el.name = 'test';
el.active = true;
await aTimeout(100);
expect(el.getAttribute('name')).to.equal('test');
expect(el.hasAttribute('active')).to.equal(true);
});
it('changing active should always update aria-hidden role', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel>Test</wa-tab-panel> `);
it('changing active should always update aria-hidden role', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel>Test</wa-tab-panel> `);
el.active = true;
await aTimeout(100);
expect(el.getAttribute('aria-hidden')).to.equal('false');
});
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<WaTabPanel>(html` <wa-tab-panel id="test-id">Test</wa-tab-panel> `);
it('passed id should be used', async () => {
const el = await fixture<WaTabPanel>(html` <wa-tab-panel id="test-id">Test</wa-tab-panel> `);
expect(el.id).to.equal('test-id');
});
expect(el.id).to.equal('test-id');
});
});
}
});

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