feat: add ESLint, improve types, improve a11y

This commit is contained in:
Jason O'Neill
2022-01-15 21:47:14 -08:00
parent 2ad00deb38
commit 9fb3b5cfed
178 changed files with 17210 additions and 1830 deletions

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,33 +1,31 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlAlert from './alert';
describe('<sl-alert>', () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
expect(base.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
expect(base.hidden).to.be.true;
});
it('should emit sl-show and sl-after-show when calling show()', async () => {
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
void el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
@@ -39,13 +37,13 @@ describe('<sl-alert>', () => {
it('should emit sl-hide and sl-after-hide when calling hide()', async () => {
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
void el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
@@ -57,7 +55,7 @@ describe('<sl-alert>', () => {
it('should emit sl-show and sl-after-show when setting open = true', async () => {
const el = await fixture<SlAlert>(html` <sl-alert>I am an alert</sl-alert> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
@@ -75,7 +73,7 @@ describe('<sl-alert>', () => {
it('should emit sl-hide and sl-after-hide when setting open = false', async () => {
const el = await fixture<SlAlert>(html` <sl-alert open>I am an alert</sl-alert> `);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();

View File

@@ -1,14 +1,12 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { animateTo, stopAnimations } from '../../internal/animate';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { waitForEvent } from '../../internal/event';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import styles from './alert.styles';
import '../icon-button/icon-button';
import '~/components/icon-button/icon-button';
import { animateTo, stopAnimations } from '~/internal/animate';
import { emit, waitForEvent } from '~/internal/event';
import { watch } from '~/internal/watch';
import { getAnimation, setDefaultAnimation } from '~/utilities/animation-registry';
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
@@ -41,7 +39,7 @@ const toastStack = Object.assign(document.createElement('div'), { className: 'sl
export default class SlAlert extends LitElement {
static styles = styles;
private autoHideTimeout: any;
private autoHideTimeout: NodeJS.Timeout;
@query('[part="base"]') base: HTMLElement;
@@ -58,7 +56,7 @@ export default class SlAlert extends LitElement {
* The length of time, in milliseconds, the alert will show before closing itself. If the user interacts with
* the alert before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`.
*/
@property({ type: Number }) duration: number = Infinity;
@property({ type: Number }) duration = Infinity;
firstUpdated() {
this.base.hidden = !this.open;
@@ -67,7 +65,7 @@ export default class SlAlert extends LitElement {
/** Shows the alert. */
async show() {
if (this.open) {
return;
return undefined;
}
this.open = true;
@@ -77,7 +75,7 @@ export default class SlAlert extends LitElement {
/** Hides the alert */
async hide() {
if (!this.open) {
return;
return undefined;
}
this.open = false;
@@ -91,7 +89,7 @@ export default class SlAlert extends LitElement {
*/
async toast() {
return new Promise<void>(resolve => {
if (!toastStack.parentElement) {
if (toastStack.parentElement === null) {
document.body.append(toastStack);
}
@@ -99,8 +97,9 @@ export default class SlAlert extends LitElement {
// Wait for the toast stack to render
requestAnimationFrame(() => {
this.clientWidth; // force a reflow for the initial transition
this.show();
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition
this.clientWidth;
void this.show();
});
this.addEventListener(
@@ -110,7 +109,7 @@ export default class SlAlert extends LitElement {
resolve();
// Remove the toast stack from the DOM when there are no more alerts
if (!toastStack.querySelector('sl-alert')) {
if (toastStack.querySelector('sl-alert') === null) {
toastStack.remove();
}
},
@@ -122,12 +121,14 @@ export default class SlAlert extends LitElement {
restartAutoHide() {
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = setTimeout(() => this.hide(), this.duration);
this.autoHideTimeout = setTimeout(() => {
void this.hide();
}, this.duration);
}
}
handleCloseClick() {
this.hide();
void this.hide();
}
handleMouseMove() {

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,8 +1,4 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
// import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlAnimatedImage from './animated-image';
import { expect, fixture, html } from '@open-wc/testing';
describe('<sl-animated-image>', () => {
it('should render a component', async () => {

View File

@@ -1,10 +1,9 @@
import { LitElement, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { watch } from '../../internal/watch';
import { emit } from '../../internal/event';
import styles from './animated-image.styles';
import '../icon/icon';
import '~/components/icon/icon';
import { emit } from '~/internal/event';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -63,7 +62,7 @@ export default class SlAnimatedImage extends LitElement {
}
@watch('play')
async handlePlayChange() {
handlePlayChange() {
// When the animation starts playing, reset the src so it plays from the beginning. Since the src is cached, this
// won't trigger another request.
if (this.play) {

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,9 +1,9 @@
import { LitElement, html } from 'lit';
import { customElement, property, queryAsync } from 'lit/decorators.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { animations } from './animations';
import styles from './animation.styles';
import { animations } from './animations';
import { emit } from '~/internal/event';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -20,7 +20,7 @@ import styles from './animation.styles';
export default class SlAnimation extends LitElement {
static styles = styles;
private animation: Animation;
private animation?: Animation;
private hasStarted = false;
@queryAsync('slot') defaultSlot: Promise<HTMLSlotElement>;
@@ -56,13 +56,13 @@ export default class SlAnimation extends LitElement {
@property() fill: FillMode = 'auto';
/** The number of iterations to run before the animation completes. Defaults to `Infinity`, which loops. */
@property({ type: Number }) iterations: number = Infinity;
@property({ type: Number }) iterations = Infinity;
/** The offset at which to start the animation, usually between 0 (start) and 1 (end). */
@property({ attribute: 'iteration-start', type: Number }) iterationStart = 0;
/** The keyframes to use for the animation. If this is set, `name` will be ignored. */
@property({ attribute: false }) keyframes: Keyframe[];
@property({ attribute: false }) keyframes?: Keyframe[];
/**
* Sets the animation's playback rate. The default is `1`, which plays the animation at a normal speed. Setting this
@@ -73,18 +73,18 @@ export default class SlAnimation extends LitElement {
/** Gets and sets the current animation time. */
get currentTime(): number {
return this.animation?.currentTime || 0;
return this.animation?.currentTime ?? 0;
}
set currentTime(time: number) {
if (this.animation) {
if (typeof this.animation !== 'undefined') {
this.animation.currentTime = time;
}
}
connectedCallback() {
super.connectedCallback();
this.createAnimation();
void this.createAnimation();
this.handleAnimationCancel = this.handleAnimationCancel.bind(this);
this.handleAnimationFinish = this.handleAnimationFinish.bind(this);
}
@@ -104,12 +104,12 @@ export default class SlAnimation extends LitElement {
@watch('iterations')
@watch('iterationsStart')
@watch('keyframes')
async handleAnimationChange() {
handleAnimationChange() {
if (!this.hasUpdated) {
return;
}
this.createAnimation();
void this.createAnimation();
}
handleAnimationFinish() {
@@ -126,39 +126,43 @@ export default class SlAnimation extends LitElement {
@watch('play')
handlePlayChange() {
if (this.animation) {
if (typeof this.animation !== 'undefined') {
if (this.play && !this.hasStarted) {
this.hasStarted = true;
emit(this, 'sl-start');
}
this.play ? this.animation.play() : this.animation.pause();
if (this.play) {
this.animation.play();
} else {
this.animation.pause();
}
return true;
} else {
return false;
}
return false;
}
@watch('playbackRate')
handlePlaybackRateChange() {
if (this.animation) {
if (typeof this.animation !== 'undefined') {
this.animation.playbackRate = this.playbackRate;
}
}
handleSlotChange() {
this.destroyAnimation();
this.createAnimation();
void this.createAnimation();
}
async createAnimation() {
const easing = animations.easings[this.easing] || this.easing;
const keyframes: Keyframe[] = this.keyframes ? this.keyframes : (animations as any)[this.name];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- The specified easing may not exist
const easing = animations.easings[this.easing] ?? this.easing;
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
const slot = await this.defaultSlot;
const element = slot.assignedElements()[0] as HTMLElement;
const element = slot.assignedElements()[0] as HTMLElement | undefined;
if (!element) {
if (typeof element === 'undefined' || typeof keyframes === 'undefined') {
return false;
}
@@ -188,7 +192,7 @@ export default class SlAnimation extends LitElement {
}
destroyAnimation() {
if (this.animation) {
if (typeof this.animation !== 'undefined') {
this.animation.cancel();
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
this.animation.removeEventListener('finish', this.handleAnimationFinish);
@@ -198,16 +202,12 @@ export default class SlAnimation extends LitElement {
/** Clears all KeyframeEffects caused by this animation and aborts its playback. */
cancel() {
try {
this.animation.cancel();
} catch {}
this.animation?.cancel();
}
/** Sets the playback time to the end of the animation corresponding to the current playback direction. */
finish() {
try {
this.animation.finish();
} catch {}
this.animation?.finish();
}
render() {

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,12 +1,10 @@
import { expect, fixture, html } from '@open-wc/testing';
import '../../../dist/shoelace.js';
import type SlAvatar from './avatar';
describe('<sl-avatar>', () => {
let el: SlAvatar;
describe('when provided no parameters', async () => {
describe('when provided no parameters', () => {
before(async () => {
el = await fixture<SlAvatar>(html` <sl-avatar label="Avatar"></sl-avatar> `);
});
@@ -15,14 +13,14 @@ describe('<sl-avatar>', () => {
await expect(el).to.be.accessible();
});
it('should default to circle styling', async () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
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', async () => {
describe('when provided an image and label parameter', () => {
const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const label = 'Small transparent square';
before(async () => {
@@ -40,20 +38,20 @@ describe('<sl-avatar>', () => {
await expect(el).to.be.accessible();
});
it('renders "image" part, with src and a role of presentation', async () => {
const part = el.shadowRoot?.querySelector('[part="image"]') as HTMLImageElement;
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', async () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
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', async () => {
describe('when provided initials parameter', () => {
const initials = 'SL';
before(async () => {
el = await fixture<SlAvatar>(html`<sl-avatar initials="${initials}" label="Avatar"></sl-avatar>`);
@@ -63,8 +61,8 @@ describe('<sl-avatar>', () => {
await expect(el).to.be.accessible();
});
it('renders "initials" part, with initials as the text node', async () => {
const part = el.shadowRoot?.querySelector('[part="initials"]') as HTMLImageElement;
it('renders "initials" part, with initials as the text node', () => {
const part = el.shadowRoot!.querySelector<HTMLElement>('[part="initials"]')!;
expect(part.innerText).to.eq(initials);
});
@@ -73,15 +71,15 @@ describe('<sl-avatar>', () => {
['square', 'rounded', 'circle'].forEach(shape => {
describe(`when passed a shape attribute ${shape}`, () => {
before(async () => {
el = await fixture<SlAvatar>(html`<sl-avatar shape="${shape as any}" label="Shaped avatar"></sl-avatar>`);
el = await fixture<SlAvatar>(html`<sl-avatar shape="${shape}" label="Shaped avatar"></sl-avatar>`);
});
it('passes accessibility test', async () => {
await expect(el).to.be.accessible();
});
it('appends the appropriate class on the "base" part', async () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
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}`);
@@ -89,7 +87,7 @@ describe('<sl-avatar>', () => {
});
});
describe('when passed a <span>, on slot "icon"', async () => {
describe('when passed a <span>, on slot "icon"', () => {
before(async () => {
el = await fixture<SlAvatar>(html`<sl-avatar label="Avatar"><span slot="icon">random content</span></sl-avatar>`);
});
@@ -98,13 +96,13 @@ describe('<sl-avatar>', () => {
await expect(el).to.be.accessible();
});
it('should accept as an assigned child in the shadow root', async () => {
const slot = <HTMLSlotElement>el.shadowRoot.querySelector('slot[name=icon]');
const childNodes = slot.assignedNodes({ flatten: true });
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 = <HTMLElement>childNodes[0];
const span = childNodes[0];
expect(span.innerHTML).to.eq('random content');
});
});

View File

@@ -2,8 +2,7 @@ import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import styles from './avatar.styles';
import '../icon/icon';
import '~/components/icon/icon';
/**
* @since 2.0
@@ -27,13 +26,13 @@ export default class SlAvatar extends LitElement {
@state() private hasError = false;
/** The image source to use for the avatar. */
@property() image: string;
@property() image?: string;
/** A label to use to describe the avatar to assistive devices. */
@property() label: string;
@property() label = '';
/** Initials to use as a fallback when no image is available (1-2 characters max recommended). */
@property() initials: string;
@property() initials?: string;
/** The shape of the avatar. */
@property({ reflect: true }) shape: 'circle' | 'square' | 'rounded' = 'circle';
@@ -51,7 +50,7 @@ export default class SlAvatar extends LitElement {
role="img"
aria-label=${this.label}
>
${this.initials
${typeof this.initials !== 'undefined'
? html` <div part="initials" class="avatar__initials">${this.initials}</div> `
: html`
<div part="icon" class="avatar__icon" aria-hidden="true">
@@ -60,7 +59,7 @@ export default class SlAvatar extends LitElement {
</slot>
</div>
`}
${this.image && !this.hasError
${typeof this.image !== 'undefined' && !this.hasError
? html`
<img
part="image"

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,12 +1,10 @@
import { expect, fixture, html } from '@open-wc/testing';
import '../../../dist/shoelace.js';
import type SlBadge from './badge';
describe('<sl-badge>', () => {
let el: SlBadge;
describe('when provided no parameters', async () => {
describe('when provided no parameters', () => {
before(async () => {
el = await fixture<SlBadge>(html` <sl-badge>Badge</sl-badge> `);
});
@@ -14,21 +12,21 @@ describe('<sl-badge>', () => {
it('should render a component that passes accessibility test, with a role of status on the base part.', async () => {
await expect(el).to.be.accessible();
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const part = el.shadowRoot!.querySelector('[part="base"]')!;
expect(part.getAttribute('role')).to.eq('status');
});
it('should render the child content provided', async () => {
it('should render the child content provided', () => {
expect(el.innerText).to.eq('Badge');
});
it('should default to square styling, with the primary color', async () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
it('should default to square styling, with the primary color', () => {
const part = el.shadowRoot!.querySelector('[part="base"]')!;
expect(part.classList.value.trim()).to.eq('badge badge--primary');
});
});
describe('when provided a pill parameter', async () => {
describe('when provided a pill parameter', () => {
before(async () => {
el = await fixture<SlBadge>(html` <sl-badge pill>Badge</sl-badge> `);
});
@@ -37,13 +35,13 @@ describe('<sl-badge>', () => {
await expect(el).to.be.accessible();
});
it('should append the pill class to the classlist to render a pill', async () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
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--primary badge--pill');
});
});
describe('when provided a pulse parameter', async () => {
describe('when provided a pulse parameter', () => {
before(async () => {
el = await fixture<SlBadge>(html` <sl-badge pulse>Badge</sl-badge> `);
});
@@ -52,8 +50,8 @@ describe('<sl-badge>', () => {
await expect(el).to.be.accessible();
});
it('should append the pulse class to the classlist to render a pulse', async () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
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--primary badge--pulse');
});
});
@@ -61,15 +59,15 @@ describe('<sl-badge>', () => {
['primary', 'success', 'neutral', 'warning', 'danger'].forEach(variant => {
describe(`when passed a variant attribute ${variant}`, () => {
before(async () => {
el = await fixture<SlBadge>(html`<sl-badge variant="${variant as any}">Badge</sl-badge>`);
el = await fixture<SlBadge>(html`<sl-badge variant="${variant}">Badge</sl-badge>`);
});
it('should render a component that passes accessibility test', async () => {
await expect(el).to.be.accessible();
});
it('should default to square styling, with the primary color', async () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
it('should default to square styling, with the primary color', () => {
const part = el.shadowRoot!.querySelector('[part="base"]')!;
expect(part.classList.value.trim()).to.eq(`badge badge--${variant}`);
});
});

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,12 +1,10 @@
import { expect, fixture, html } from '@open-wc/testing';
import '../../../dist/shoelace.js';
import type SlBreadcrumbItem from './breadcrumb-item';
describe('<sl-breadcrumb-item>', () => {
let el: SlBreadcrumbItem;
describe('when not provided a href attribute', async () => {
describe('when not provided a href attribute', () => {
before(async () => {
el = await fixture<SlBreadcrumbItem>(html` <sl-breadcrumb-item>Home</sl-breadcrumb-item> `);
});
@@ -15,19 +13,19 @@ describe('<sl-breadcrumb-item>', () => {
await expect(el).to.be.accessible();
});
it('should hide the separator from screen readers', async () => {
const separator: HTMLSpanElement = el.shadowRoot.querySelector('[part="separator"]');
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: HTMLButtonElement = el.shadowRoot.querySelector('[part="label"]');
const button = el.shadowRoot!.querySelector<HTMLButtonElement>('[part="label"]');
expect(button).to.exist;
expect(button).attribute('type', 'button');
});
});
describe('when provided a href attribute', async () => {
describe('when provided a href attribute', () => {
describe('and no target', () => {
before(async () => {
el = await fixture<SlBreadcrumbItem>(html`
@@ -40,7 +38,7 @@ describe('<sl-breadcrumb-item>', () => {
});
it('should render a HTMLAnchorElement as the part "label", with the supplied href value', () => {
const hyperlink: HTMLAnchorElement = el.shadowRoot.querySelector('[part="label"]');
const hyperlink = el.shadowRoot!.querySelector<HTMLAnchorElement>('[part="label"]');
expect(hyperlink).attribute('href', 'https://jsonplaceholder.typicode.com/');
});
});
@@ -57,10 +55,10 @@ describe('<sl-breadcrumb-item>', () => {
});
describe('should render a HTMLAnchorElement as the part "label"', () => {
let hyperlink: HTMLAnchorElement;
let hyperlink: HTMLAnchorElement | null;
before(() => {
hyperlink = el.shadowRoot.querySelector('[part="label"]');
hyperlink = el.shadowRoot!.querySelector<HTMLAnchorElement>('[part="label"]');
});
it('should use the supplied href value, as the href attribute value', () => {
@@ -87,10 +85,10 @@ describe('<sl-breadcrumb-item>', () => {
});
describe('should render a HTMLAnchorElement', () => {
let hyperlink: HTMLAnchorElement;
let hyperlink: HTMLAnchorElement | null;
before(() => {
hyperlink = el.shadowRoot.querySelector('a');
hyperlink = el.shadowRoot!.querySelector<HTMLAnchorElement>('a');
});
it('should use the supplied href value, as the href attribute value', () => {
@@ -104,7 +102,7 @@ describe('<sl-breadcrumb-item>', () => {
});
});
describe('when provided an element in the slot "prefix" to support prefix icons', async () => {
describe('when provided an element in the slot "prefix" to support prefix icons', () => {
before(async () => {
el = await fixture<SlBreadcrumbItem>(html`
<sl-breadcrumb-item>
@@ -119,19 +117,19 @@ describe('<sl-breadcrumb-item>', () => {
});
it('should accept as an assigned child in the shadow root', () => {
const slot = <HTMLSlotElement>el.shadowRoot.querySelector('slot[name=prefix]');
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=prefix]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should append class "breadcrumb-item--has-prefix" to "base" part', () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const part = el.shadowRoot!.querySelector('[part="base"]')!;
expect(part.classList.value.trim()).to.equal('breadcrumb-item breadcrumb-item--has-prefix');
});
});
describe('when provided an element in the slot "suffix" to support suffix icons', async () => {
describe('when provided an element in the slot "suffix" to support suffix icons', () => {
before(async () => {
el = await fixture<SlBreadcrumbItem>(html`
<sl-breadcrumb-item>
@@ -146,14 +144,14 @@ describe('<sl-breadcrumb-item>', () => {
});
it('should accept as an assigned child in the shadow root', () => {
const slot = <HTMLSlotElement>el.shadowRoot.querySelector('slot[name=suffix]');
const slot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=suffix]')!;
const childNodes = slot.assignedNodes({ flatten: true });
expect(childNodes.length).to.eq(1);
});
it('should append class "breadcrumb-item--has-suffix" to "base" part', () => {
const part = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const part = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
expect(part.classList.value.trim()).to.equal('breadcrumb-item breadcrumb-item--has-suffix');
});
});

View File

@@ -2,8 +2,8 @@ import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { HasSlotController } from '../../internal/slot';
import styles from './breadcrumb-item.styles';
import { HasSlotController } from '~/internal/slot';
/**
* @since 2.0
@@ -25,22 +25,22 @@ import styles from './breadcrumb-item.styles';
export default class SlBreadcrumbItem extends LitElement {
static styles = styles;
private hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
private readonly hasSlotController = new HasSlotController(this, 'prefix', 'suffix');
/**
* Optional URL to direct the user to when the breadcrumb item is activated. When set, a link will be rendered
* internally. When unset, a button will be rendered instead.
*/
@property() href: string;
@property() href?: string;
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target: '_blank' | '_parent' | '_self' | '_top';
@property() target?: '_blank' | '_parent' | '_self' | '_top';
/** The `rel` attribute to use on the link. Only used when `href` is set. */
@property() rel: string = 'noreferrer noopener';
@property() rel = 'noreferrer noopener';
render() {
const isLink = this.href ? true : false;
const isLink = typeof this.href !== 'undefined';
return html`
<div
@@ -62,7 +62,7 @@ export default class SlBreadcrumbItem extends LitElement {
class="breadcrumb-item__label breadcrumb-item__label--link"
href="${this.href}"
target="${this.target}"
rel=${ifDefined(this.target ? this.rel : undefined)}
rel=${ifDefined(typeof this.target !== 'undefined' ? this.rel : undefined)}
>
<slot></slot>
</a>

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,12 +1,10 @@
import { expect, fixture, html } from '@open-wc/testing';
import '../../../dist/shoelace.js';
import type SlBreadcrumb from './breadcrumb';
describe('<sl-breadcrumb>', () => {
let el: SlBreadcrumb;
describe('when provided a standard list of el-breadcrumb-item children and no parameters', async () => {
describe('when provided a standard list of el-breadcrumb-item children and no parameters', () => {
before(async () => {
el = await fixture<SlBreadcrumb>(html`
<sl-breadcrumb>
@@ -22,18 +20,18 @@ describe('<sl-breadcrumb>', () => {
await expect(el).to.be.accessible();
});
it('should render sl-icon as separator', async () => {
it('should render sl-icon as separator', () => {
expect(el.querySelectorAll('sl-icon').length).to.eq(4);
});
it('should attach aria-current "page" on the last breadcrumb item.', async () => {
it('should attach aria-current "page" on the last breadcrumb item.', () => {
const breadcrumbItems = el.querySelectorAll('sl-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', async () => {
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<SlBreadcrumb>(html`
<sl-breadcrumb>
@@ -49,20 +47,20 @@ describe('<sl-breadcrumb>', () => {
await expect(el).to.be.accessible();
});
it('should accept "separator" as an assigned child in the shadow root', async () => {
const slot = <HTMLSlotElement>el.shadowRoot.querySelector('slot[name=separator]');
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 sl-icon separator with the provided separator', async () => {
it('should replace the sl-icon separator with the provided separator', () => {
expect(el.querySelectorAll('.replacement-separator').length).to.eq(4);
expect(el.querySelectorAll('sl-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', async () => {
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<SlBreadcrumb>(html`
<sl-breadcrumb>
@@ -82,7 +80,7 @@ describe('<sl-breadcrumb>', () => {
});
});
describe('when provided a standard list of el-breadcrumb-item children and an element in the slot "suffix" to support suffix icons', async () => {
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<SlBreadcrumb>(html`
<sl-breadcrumb>

View File

@@ -1,9 +1,8 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import styles from './breadcrumb.styles';
import type SlBreadcrumbItem from '../breadcrumb-item/breadcrumb-item';
import '../icon/icon';
import type SlBreadcrumbItem from '~/components/breadcrumb-item/breadcrumb-item';
import '~/components/icon/icon';
/**
* @since 2.0
@@ -35,7 +34,9 @@ export default class SlBreadcrumb extends LitElement {
// Clone it, remove ids, and slot it
const clone = separator.cloneNode(true) as HTMLElement;
[clone, ...clone.querySelectorAll('[id]')].map(el => el.removeAttribute('id'));
[clone, ...clone.querySelectorAll('[id]')].forEach(el => {
el.removeAttribute('id');
});
clone.slot = 'separator';
return clone;
@@ -46,10 +47,10 @@ export default class SlBreadcrumb extends LitElement {
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
) as SlBreadcrumbItem[];
items.map((item, index) => {
items.forEach((item, index) => {
// Append separators to each item if they don't already have one
const separator = item.querySelector('[slot="separator"]') as HTMLElement;
if (!separator) {
const separator = item.querySelector('[slot="separator"]');
if (separator === null) {
item.append(this.getSeparator());
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -42,11 +42,11 @@ export default class SlButtonGroup extends LitElement {
handleSlotChange() {
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
slottedElements.map(el => {
slottedElements.forEach(el => {
const index = slottedElements.indexOf(el);
const button = findButton(el);
if (button) {
if (button !== null) {
button.classList.add('sl-button-group__button');
button.classList.toggle('sl-button-group__button--first', index === 0);
button.classList.toggle('sl-button-group__button--inner', index > 0 && index < slottedElements.length - 1);
@@ -56,6 +56,7 @@ export default class SlButtonGroup extends LitElement {
}
render() {
// eslint-disable-next-line lit-a11y/mouse-events-have-key-events -- focusout & focusin support bubbling where as focus & blur do not which is necessary here
return html`
<div
part="base"

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,14 +1,13 @@
import { LitElement } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { html, literal } from 'lit/static-html.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form-control';
import { HasSlotController } from '../../internal/slot';
import { html, literal } from 'lit/static-html.js';
import styles from './button.styles';
import '../spinner/spinner';
import '~/components/spinner/spinner';
import { emit } from '~/internal/event';
import { FormSubmitController } from '~/internal/form-control';
import { HasSlotController } from '~/internal/slot';
/**
* @since 2.0
@@ -35,8 +34,8 @@ export default class SlButton extends LitElement {
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
private formSubmitController = new FormSubmitController(this);
private hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
@state() private hasFocus = false;
@@ -72,19 +71,19 @@ export default class SlButton extends LitElement {
@property() type: 'button' | 'submit' = 'button';
/** An optional name for the button. Ignored when `href` is set. */
@property() name: string;
@property() name?: string;
/** An optional value for the button. Ignored when `href` is set. */
@property() value: string;
@property() value?: string;
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href: string;
@property() href?: string;
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target: '_blank' | '_parent' | '_self' | '_top';
@property() target?: '_blank' | '_parent' | '_self' | '_top';
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
@property() download: string;
@property() download?: string;
/** Simulates a click on the button. */
click() {
@@ -124,9 +123,10 @@ export default class SlButton extends LitElement {
}
render() {
const isLink = this.href ? true : false;
const isLink = typeof this.href !== 'undefined';
const tag = isLink ? literal`a` : literal`button`;
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
return html`
<${tag}
part="base"
@@ -161,7 +161,7 @@ export default class SlButton extends LitElement {
href=${ifDefined(this.href)}
target=${ifDefined(this.target)}
download=${ifDefined(this.download)}
rel=${ifDefined(this.target ? 'noreferrer noopener' : undefined)}
rel=${ifDefined(typeof this.target !== 'undefined' ? 'noreferrer noopener' : undefined)}
role="button"
aria-disabled=${this.disabled ? 'true' : 'false'}
tabindex=${this.disabled ? '-1' : '0'}
@@ -199,6 +199,7 @@ export default class SlButton extends LitElement {
${this.loading ? html`<sl-spinner></sl-spinner>` : ''}
</${tag}>
`;
/* eslint-enable lit/binding-positions, lit/no-invalid-html */
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,12 +1,10 @@
import { expect, fixture, html } from '@open-wc/testing';
import '../../../dist/shoelace.js';
import type SlCard from './card';
describe('<sl-card>', () => {
let el: SlCard;
describe('when provided no parameters', async () => {
describe('when provided no parameters', () => {
before(async () => {
el = await fixture<SlCard>(
html` <sl-card>This is just a basic card. No image, no header, and no footer. Just your content.</sl-card> `
@@ -17,17 +15,17 @@ describe('<sl-card>', () => {
await expect(el).to.be.accessible();
});
it('should render the child content provided.', async () => {
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.', async () => {
const card = el.shadowRoot.querySelector('.card') as HTMLElement;
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', async () => {
describe('when provided an element in the slot "header" to render a header', () => {
before(async () => {
el = await fixture<SlCard>(
html`<sl-card>
@@ -41,29 +39,29 @@ describe('<sl-card>', () => {
await expect(el).to.be.accessible();
});
it('should render the child content provided.', async () => {
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.', async () => {
const header = <HTMLDivElement>el.querySelector('div[slot=header]');
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.', async () => {
const slot = <HTMLSlotElement>el.shadowRoot.querySelector('slot[name=header]');
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.', async () => {
const card = el.shadowRoot.querySelector('.card') as HTMLElement;
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', async () => {
describe('when provided an element in the slot "footer" to render a footer', () => {
before(async () => {
el = await fixture<SlCard>(
html`<sl-card>
@@ -78,35 +76,35 @@ describe('<sl-card>', () => {
await expect(el).to.be.accessible();
});
it('should render the child content provided.', async () => {
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.', async () => {
const footer = <HTMLDivElement>el.querySelector('div[slot=footer]');
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.', async () => {
const slot = <HTMLSlotElement>el.shadowRoot.querySelector('slot[name=footer]');
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.', async () => {
const card = el.shadowRoot.querySelector('.card') as HTMLElement;
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', async () => {
describe('when provided an element in the slot "image" to render a image', () => {
before(async () => {
el = await fixture<SlCard>(
html`<sl-card>
<img
slot="image"
src="https://images.unsplash.com/photo-1547191783-94d5f8f6d8b1?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=400&q=80"
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.
@@ -118,21 +116,21 @@ describe('<sl-card>', () => {
await expect(el).to.be.accessible();
});
it('should render the child content provided.', async () => {
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.', async () => {
const slot = <HTMLSlotElement>el.shadowRoot.querySelector('slot[name=image]');
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.', async () => {
const card = el.shadowRoot.querySelector('.card') as HTMLElement;
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,8 +1,8 @@
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { HasSlotController } from '../../internal/slot';
import styles from './card.styles';
import { HasSlotController } from '~/internal/slot';
/**
* @since 2.0
@@ -28,7 +28,7 @@ import styles from './card.styles';
export default class SlCard extends LitElement {
static styles = styles;
private hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
render() {
return html`

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,7 +1,5 @@
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import '../../../dist/shoelace.js';
import type SlCheckbox from './checkbox';
describe('<sl-checkbox>', () => {
@@ -9,7 +7,7 @@ describe('<sl-checkbox>', () => {
const el = await fixture<SlCheckbox>(html` <sl-checkbox disabled></sl-checkbox> `);
const checkbox = el.shadowRoot?.querySelector('input');
expect(checkbox.disabled).to.be.true;
expect(checkbox!.disabled).to.be.true;
});
it('should be valid by default', async () => {
@@ -20,7 +18,9 @@ describe('<sl-checkbox>', () => {
it('should fire sl-change when clicked', async () => {
const el = await fixture<SlCheckbox>(html` <sl-checkbox></sl-checkbox> `);
setTimeout(() => el.shadowRoot?.querySelector('input').click());
setTimeout(() => {
el.shadowRoot!.querySelector('input')!.click();
});
const event = await oneEvent(el, 'sl-change');
expect(event.target).to.equal(el);
expect(el.checked).to.be.true;
@@ -28,9 +28,11 @@ describe('<sl-checkbox>', () => {
it('should fire sl-change when toggled via keyboard', async () => {
const el = await fixture<SlCheckbox>(html` <sl-checkbox></sl-checkbox> `);
const input = el.shadowRoot?.querySelector('input');
const input = el.shadowRoot!.querySelector('input')!;
input.focus();
setTimeout(() => sendKeys({ press: ' ' }));
setTimeout(() => {
void sendKeys({ press: ' ' });
});
const event = await oneEvent(el, 'sl-change');
expect(event.target).to.equal(el);
expect(el.checked).to.be.true;

View File

@@ -3,12 +3,11 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { FormSubmitController } from '../../internal/form-control';
import styles from './checkbox.styles';
let id = 0;
import { autoIncrement } from '~/internal/autoIncrement';
import { emit } from '~/internal/event';
import { FormSubmitController } from '~/internal/form-control';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -32,12 +31,13 @@ export default class SlCheckbox extends LitElement {
@query('input[type="checkbox"]') input: HTMLInputElement;
// @ts-ignore
private formSubmitController = new FormSubmitController(this, {
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlCheckbox) => (control.checked ? control.value : undefined)
});
private inputId = `checkbox-${++id}`;
private labelId = `checkbox-label-${id}`;
private readonly attrId = autoIncrement();
private readonly inputId = `checkbox-${this.attrId}`;
private readonly labelId = `checkbox-label-${this.attrId}`;
@state() private hasFocus = false;
@@ -103,13 +103,11 @@ export default class SlCheckbox extends LitElement {
emit(this, 'sl-blur');
}
@watch('disabled')
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.input) {
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
@@ -146,7 +144,6 @@ export default class SlCheckbox extends LitElement {
.checked=${live(this.checked)}
.disabled=${this.disabled}
.required=${this.required}
role="checkbox"
aria-checked=${this.checked ? 'true' : 'false'}
aria-labelledby=${this.labelId}
@click=${this.handleClick}

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,13 +1,11 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlColorPicker from './color-picker';
describe('<sl-color-picker>', () => {
it('should emit change and show correct color when the value changes', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const trigger = el.shadowRoot.querySelector('[part="trigger"]') as HTMLElement;
const trigger = el.shadowRoot!.querySelector<HTMLElement>('[part="trigger"]')!;
const changeHandler = sinon.spy();
const color = 'rgb(255, 204, 0)';
@@ -22,21 +20,21 @@ describe('<sl-color-picker>', () => {
it('should render in a dropdown', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker></sl-color-picker> `);
const dropdown = el.shadowRoot.querySelector('sl-dropdown');
const dropdown = el.shadowRoot!.querySelector('sl-dropdown');
expect(dropdown).to.exist;
});
it('should not render in a dropdown when inline is enabled', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker inline></sl-color-picker> `);
const dropdown = el.shadowRoot.querySelector('sl-dropdown');
const dropdown = el.shadowRoot!.querySelector('sl-dropdown');
expect(dropdown).to.not.exist;
});
it('should show opacity slider when opacity is enabled', async () => {
const el = await fixture<SlColorPicker>(html` <sl-color-picker opacity></sl-color-picker> `);
const opacitySlider = el.shadowRoot.querySelector('[part*="opacity-slider"]') as HTMLElement;
const opacitySlider = el.shadowRoot!.querySelector('[part*="opacity-slider"]')!;
expect(opacitySlider).to.exist;
});

View File

@@ -1,27 +1,37 @@
import Color from 'color';
import { LitElement, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { styleMap } from 'lit/directives/style-map.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { clamp } from '../../internal/math';
import { FormSubmitController } from '../../internal/form-control';
import { LocalizeController } from '../../utilities/localize';
import type SlDropdown from '../dropdown/dropdown';
import type SlInput from '../input/input';
import color from 'color';
import styles from './color-picker.styles';
import '../button/button';
import '../button-group/button-group';
import '../dropdown/dropdown';
import '../icon/icon';
import '../input/input';
import '~/components/button-group/button-group';
import '~/components/button/button';
import type SlDropdown from '~/components/dropdown/dropdown';
import '~/components/dropdown/dropdown';
import '~/components/icon/icon';
import type SlInput from '~/components/input/input';
import '~/components/input/input';
import { drag } from '~/internal/drag';
import { emit } from '~/internal/event';
import { FormSubmitController } from '~/internal/form-control';
import { clamp } from '~/internal/math';
import { watch } from '~/internal/watch';
import { LocalizeController } from '~/utilities/localize';
const hasEyeDropper = 'EyeDropper' in window;
interface EyeDropperConstructor {
new (): EyeDropperInterface;
}
interface EyeDropperInterface {
open: () => Promise<{ sRGBHex: string }>;
}
declare const EyeDropper: EyeDropperConstructor;
/**
* @since 2.0
* @status stable
@@ -63,11 +73,11 @@ export default class SlColorPicker extends LitElement {
@query('[part="preview"]') previewButton: HTMLButtonElement;
@query('.color-dropdown') dropdown: SlDropdown;
// @ts-ignore
private formSubmitController = new FormSubmitController(this);
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private isSafeValue = false;
private lastValueEmitted: string;
private localize = new LocalizeController(this);
private readonly localize = new LocalizeController(this);
@state() private inputValue = '';
@state() private hue = 0;
@@ -151,7 +161,7 @@ export default class SlColorPicker extends LitElement {
this.inputValue = this.value;
this.lastValueEmitted = this.value;
this.syncValues();
void this.syncValues();
}
/** Returns the current value as a string in the specified format. */
@@ -160,7 +170,7 @@ export default class SlColorPicker extends LitElement {
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
);
if (!currentColor) {
if (currentColor === null) {
return '';
}
@@ -195,11 +205,10 @@ export default class SlColorPicker extends LitElement {
},
{ once: true }
);
this.dropdown.show();
void this.dropdown.show();
});
} else {
return this.input.reportValidity();
}
return this.input.reportValidity();
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
@@ -215,9 +224,9 @@ export default class SlColorPicker extends LitElement {
// Show copied animation
this.previewButton.classList.add('color-picker__preview-color--copied');
this.previewButton.addEventListener('animationend', () =>
this.previewButton.classList.remove('color-picker__preview-color--copied')
);
this.previewButton.addEventListener('animationend', () => {
this.previewButton.classList.remove('color-picker__preview-color--copied');
});
}
handleFormatToggle() {
@@ -226,106 +235,74 @@ export default class SlColorPicker extends LitElement {
this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl';
}
handleAlphaDrag(event: any) {
const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__alpha') as HTMLElement;
const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement;
handleAlphaDrag(event: Event) {
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__alpha')!;
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
const { width } = container.getBoundingClientRect();
handle.focus();
event.preventDefault();
this.handleDrag(event, container, x => {
drag(container, x => {
this.alpha = clamp((x / width) * 100, 0, 100);
this.syncValues();
void this.syncValues();
});
}
handleHueDrag(event: any) {
const container = this.shadowRoot!.querySelector('.color-picker__slider.color-picker__hue') as HTMLElement;
const handle = container.querySelector('.color-picker__slider-handle') as HTMLElement;
handleHueDrag(event: Event) {
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__hue')!;
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
const { width } = container.getBoundingClientRect();
handle.focus();
event.preventDefault();
this.handleDrag(event, container, x => {
drag(container, x => {
this.hue = clamp((x / width) * 360, 0, 360);
this.syncValues();
void this.syncValues();
});
}
handleGridDrag(event: any) {
const grid = this.shadowRoot!.querySelector('.color-picker__grid') as HTMLElement;
const handle = grid.querySelector('.color-picker__grid-handle') as HTMLElement;
handleGridDrag(event: Event) {
const grid = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__grid')!;
const handle = grid.querySelector<HTMLElement>('.color-picker__grid-handle')!;
const { width, height } = grid.getBoundingClientRect();
handle.focus();
event.preventDefault();
this.handleDrag(event, grid, (x, y) => {
drag(grid, (x, y) => {
this.saturation = clamp((x / width) * 100, 0, 100);
this.lightness = clamp(100 - (y / height) * 100, 0, 100);
this.syncValues();
void this.syncValues();
});
}
handleDrag(event: any, container: HTMLElement, onMove: (x: number, y: number) => void) {
if (this.disabled) {
return;
}
const move = (event: any) => {
const dims = container.getBoundingClientRect();
const defaultView = container.ownerDocument.defaultView!;
const offsetX = dims.left + defaultView.pageXOffset;
const offsetY = dims.top + defaultView.pageYOffset;
const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX;
const y = (event.changedTouches ? event.changedTouches[0].pageY : event.pageY) - offsetY;
onMove(x, y);
};
// Move on init
move(event);
const stop = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('touchmove', move);
document.removeEventListener('mouseup', stop);
document.removeEventListener('touchend', stop);
};
document.addEventListener('mousemove', move);
document.addEventListener('touchmove', move);
document.addEventListener('mouseup', stop);
document.addEventListener('touchend', stop);
}
handleAlphaKeyDown(event: KeyboardEvent) {
const increment = event.shiftKey ? 10 : 1;
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.alpha = clamp(this.alpha - increment, 0, 100);
this.syncValues();
void this.syncValues();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
this.alpha = clamp(this.alpha + increment, 0, 100);
this.syncValues();
void this.syncValues();
}
if (event.key === 'Home') {
event.preventDefault();
this.alpha = 0;
this.syncValues();
void this.syncValues();
}
if (event.key === 'End') {
event.preventDefault();
this.alpha = 100;
this.syncValues();
void this.syncValues();
}
}
@@ -335,25 +312,25 @@ export default class SlColorPicker extends LitElement {
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.hue = clamp(this.hue - increment, 0, 360);
this.syncValues();
void this.syncValues();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
this.hue = clamp(this.hue + increment, 0, 360);
this.syncValues();
void this.syncValues();
}
if (event.key === 'Home') {
event.preventDefault();
this.hue = 0;
this.syncValues();
void this.syncValues();
}
if (event.key === 'End') {
event.preventDefault();
this.hue = 360;
this.syncValues();
void this.syncValues();
}
}
@@ -363,25 +340,25 @@ export default class SlColorPicker extends LitElement {
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.saturation = clamp(this.saturation - increment, 0, 100);
this.syncValues();
void this.syncValues();
}
if (event.key === 'ArrowRight') {
event.preventDefault();
this.saturation = clamp(this.saturation + increment, 0, 100);
this.syncValues();
void this.syncValues();
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.lightness = clamp(this.lightness + increment, 0, 100);
this.syncValues();
void this.syncValues();
}
if (event.key === 'ArrowDown') {
event.preventDefault();
this.lightness = clamp(this.lightness - increment, 0, 100);
this.syncValues();
void this.syncValues();
}
}
@@ -397,7 +374,9 @@ export default class SlColorPicker extends LitElement {
if (event.key === 'Enter') {
this.setColor(this.input.value);
this.input.value = this.value;
setTimeout(() => this.input.select());
setTimeout(() => {
this.input.select();
});
}
}
@@ -419,7 +398,7 @@ export default class SlColorPicker extends LitElement {
}
if (rgba[3].indexOf('%') > -1) {
rgba[3] = (Number(rgba[3].replace(/%/g, '')) / 100).toString();
rgba[3] = (parseFloat(rgba[3].replace(/%/g, '')) / 100).toString();
}
return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${rgba[3]})`;
@@ -437,7 +416,7 @@ export default class SlColorPicker extends LitElement {
}
if (hsla[3].indexOf('%') > -1) {
hsla[3] = (Number(hsla[3].replace(/%/g, '')) / 100).toString();
hsla[3] = (parseFloat(hsla[3].replace(/%/g, '')) / 100).toString();
}
return `hsla(${hsla[0]}, ${hsla[1]}, ${hsla[2]}, ${hsla[3]})`;
@@ -451,41 +430,40 @@ export default class SlColorPicker extends LitElement {
}
parseColor(colorString: string) {
function toHex(value: number) {
const hex = Math.round(value).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
}
let parsed: any;
let parsed: Color;
// The color module has a weak parser, so we normalize certain things to make the user experience better
colorString = this.normalizeColorString(colorString);
try {
parsed = color(colorString);
parsed = Color(colorString);
} catch {
return false;
return null;
}
const hslColor = parsed.hsl();
const hsl = {
h: parsed.hsl().color[0],
s: parsed.hsl().color[1],
l: parsed.hsl().color[2],
a: parsed.hsl().valpha
h: hslColor.hue(),
s: hslColor.saturationl(),
l: hslColor.lightness(),
a: hslColor.alpha()
};
const rgbColor = parsed.rgb();
const rgb = {
r: parsed.rgb().color[0],
g: parsed.rgb().color[1],
b: parsed.rgb().color[2],
a: parsed.rgb().valpha
r: rgbColor.red(),
g: rgbColor.green(),
b: rgbColor.blue(),
a: rgbColor.alpha()
};
const hex = {
r: toHex(parsed.rgb().color[0]),
g: toHex(parsed.rgb().color[1]),
b: toHex(parsed.rgb().color[2]),
a: toHex(parsed.rgb().valpha * 255)
r: toHex(rgb.r),
g: toHex(rgb.g),
b: toHex(rgb.b),
a: toHex(rgb.a * 255)
};
return {
@@ -501,9 +479,7 @@ export default class SlColorPicker extends LitElement {
l: hsl.l,
a: hsl.a,
string: this.setLetterCase(
`hsla(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%, ${Number(
hsl.a.toFixed(2).toString()
)})`
`hsla(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%, ${hsl.a.toFixed(2).toString()})`
)
},
rgb: {
@@ -518,9 +494,7 @@ export default class SlColorPicker extends LitElement {
b: rgb.b,
a: rgb.a,
string: this.setLetterCase(
`rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${Number(
rgb.a.toFixed(2).toString()
)})`
`rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${rgb.a.toFixed(2).toString()})`
)
},
hex: this.setLetterCase(`#${hex.r}${hex.g}${hex.b}`),
@@ -531,7 +505,7 @@ export default class SlColorPicker extends LitElement {
setColor(colorString: string) {
const newColor = this.parseColor(colorString);
if (!newColor) {
if (newColor === null) {
return false;
}
@@ -540,13 +514,15 @@ export default class SlColorPicker extends LitElement {
this.lightness = newColor.hsla.l;
this.alpha = this.opacity ? newColor.hsla.a * 100 : 100;
this.syncValues();
void this.syncValues();
return true;
}
setLetterCase(string: string) {
if (typeof string !== 'string') return '';
if (typeof string !== 'string') {
return '';
}
return this.uppercase ? string.toUpperCase() : string.toLowerCase();
}
@@ -555,7 +531,7 @@ export default class SlColorPicker extends LitElement {
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
);
if (!currentColor) {
if (currentColor === null) {
return;
}
@@ -586,12 +562,11 @@ export default class SlColorPicker extends LitElement {
return;
}
// @ts-ignore
const eyeDropper = new EyeDropper();
eyeDropper
.open()
.then((colorSelectionResult: any) => this.setColor(colorSelectionResult.sRGBHex))
.then(colorSelectionResult => this.setColor(colorSelectionResult.sRGBHex))
.catch(() => {
// The user canceled, do nothing
});
@@ -599,7 +574,7 @@ export default class SlColorPicker extends LitElement {
@watch('format')
handleFormatChange() {
this.syncValues();
void this.syncValues();
}
@watch('opacity')
@@ -608,11 +583,11 @@ export default class SlColorPicker extends LitElement {
}
@watch('value')
handleValueChange(oldValue: string, newValue: string) {
if (!this.isSafeValue) {
handleValueChange(oldValue: string | undefined, newValue: string) {
if (!this.isSafeValue && oldValue !== undefined) {
const newColor = this.parseColor(newValue);
if (newColor) {
if (newColor !== null) {
this.inputValue = this.value;
this.hue = newColor.hsla.h;
this.saturation = newColor.hsla.s;
@@ -658,11 +633,8 @@ export default class SlColorPicker extends LitElement {
left: `${x}%`,
backgroundColor: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%)`
})}
role="slider"
role="application"
aria-label="HSL"
aria-valuetext=${`hsl(${Math.round(this.hue)}, ${Math.round(this.saturation)}%, ${Math.round(
this.lightness
)}%)`}
tabindex=${ifDefined(this.disabled ? undefined : '0')}
@keydown=${this.handleGridKeyDown}
></span>
@@ -743,7 +715,7 @@ export default class SlColorPicker extends LitElement {
></button>
</div>
<div class="color-picker__user-input">
<div class="color-picker__user-input" aria-live="polite">
<sl-input
part="input"
type="text"
@@ -762,7 +734,7 @@ export default class SlColorPicker extends LitElement {
${!this.noFormatToggle
? html`
<sl-button
aria-label=${this.localize.term('toggle_color_format')}
aria-label=${this.localize.term('toggleColorFormat')}
exportparts="base:format-button"
@click=${this.handleFormatToggle}
>
@@ -776,7 +748,7 @@ export default class SlColorPicker extends LitElement {
<sl-icon
library="system"
name="eyedropper"
label=${this.localize.term('select_a_color_from_the_screen')}
label=${this.localize.term('selectAColorFromTheScreen')}
></sl-icon>
</sl-button>
`
@@ -784,7 +756,7 @@ export default class SlColorPicker extends LitElement {
</sl-button-group>
</div>
${this.swatches
${this.swatches.length > 0
? html`
<div part="swatches" class="color-picker__swatches">
${this.swatches.map(swatch => {
@@ -828,12 +800,14 @@ export default class SlColorPicker extends LitElement {
part="trigger"
slot="trigger"
class=${classMap({
/* eslint-disable @typescript-eslint/naming-convention */
'color-dropdown__trigger': true,
'color-dropdown__trigger--disabled': this.disabled,
'color-dropdown__trigger--small': this.size === 'small',
'color-dropdown__trigger--medium': this.size === 'medium',
'color-dropdown__trigger--large': this.size === 'large',
'color-picker__transparent-bg': true
/* eslint-enable @typescript-eslint/naming-convention */
})}
style=${styleMap({
color: `hsla(${this.hue}deg, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
@@ -846,6 +820,11 @@ export default class SlColorPicker extends LitElement {
}
}
function toHex(value: number) {
const hex = Math.round(value).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
}
declare global {
interface HTMLElementTagNameMap {
'sl-color-picker': SlColorPicker;

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,8 +1,6 @@
// cspell:dictionaries lorem-ipsum
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlDetails from './details';
describe('<sl-details>', () => {
@@ -14,7 +12,7 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
expect(body.hidden).to.be.false;
});
@@ -27,7 +25,7 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
expect(body.hidden).to.be.true;
});
@@ -40,13 +38,13 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
void el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
@@ -64,13 +62,13 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
void el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
@@ -88,7 +86,7 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
@@ -112,7 +110,7 @@ describe('<sl-details>', () => {
consequat.
</sl-details>
`);
const body = el.shadowRoot?.querySelector('.details__body') as HTMLElement;
const body = el.shadowRoot!.querySelector<HTMLElement>('.details__body')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();

View File

@@ -1,14 +1,12 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { animateTo, stopAnimations, shimKeyframesHeightAuto } from '../../internal/animate';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { waitForEvent } from '../../internal/event';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry';
import styles from './details.styles';
import '../icon/icon';
import '~/components/icon/icon';
import { animateTo, stopAnimations, shimKeyframesHeightAuto } from '~/internal/animate';
import { emit, waitForEvent } from '~/internal/event';
import { watch } from '~/internal/watch';
import { getAnimation, setDefaultAnimation } from '~/utilities/animation-registry';
/**
* @since 2.0
@@ -58,7 +56,7 @@ export default class SlDetails extends LitElement {
/** Shows the details. */
async show() {
if (this.open) {
return;
return undefined;
}
this.open = true;
@@ -68,16 +66,24 @@ export default class SlDetails extends LitElement {
/** Hides the details */
async hide() {
if (!this.open) {
return;
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
toggleOpen() {
if (this.open) {
void this.hide();
} else {
void this.show();
}
}
handleSummaryClick() {
if (!this.disabled) {
this.open ? this.hide() : this.show();
this.toggleOpen();
this.header.focus();
}
}
@@ -85,17 +91,17 @@ export default class SlDetails extends LitElement {
handleSummaryKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.open ? this.hide() : this.show();
this.toggleOpen();
}
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault();
this.hide();
void this.hide();
}
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault();
this.show();
void this.show();
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,8 +1,6 @@
// cspell:dictionaries lorem-ipsum
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlDialog from './dialog';
describe('<sl-dialog>', () => {
@@ -10,7 +8,7 @@ describe('<sl-dialog>', () => {
const el = await fixture<SlDialog>(html`
<sl-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
expect(base.hidden).to.be.false;
});
@@ -19,7 +17,7 @@ describe('<sl-dialog>', () => {
const el = await fixture<SlDialog>(
html` <sl-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog> `
);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
expect(base.hidden).to.be.true;
});
@@ -28,13 +26,13 @@ describe('<sl-dialog>', () => {
const el = await fixture<SlDialog>(html`
<sl-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
void el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
@@ -48,13 +46,13 @@ describe('<sl-dialog>', () => {
const el = await fixture<SlDialog>(html`
<sl-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
void el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
@@ -68,7 +66,7 @@ describe('<sl-dialog>', () => {
const el = await fixture<SlDialog>(html`
<sl-dialog>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
@@ -88,7 +86,7 @@ describe('<sl-dialog>', () => {
const el = await fixture<SlDialog>(html`
<sl-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
@@ -108,9 +106,11 @@ describe('<sl-dialog>', () => {
const el = await fixture<SlDialog>(html`
<sl-dialog open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-dialog>
`);
const overlay = el.shadowRoot?.querySelector('[part="overlay"]') as HTMLElement;
const overlay = el.shadowRoot!.querySelector<HTMLElement>('[part="overlay"]')!;
el.addEventListener('sl-request-close', event => event.preventDefault());
el.addEventListener('sl-request-close', event => {
event.preventDefault();
});
overlay.click();
expect(el.open).to.be.true;
@@ -118,14 +118,14 @@ describe('<sl-dialog>', () => {
it('should allow initial focus to be set', async () => {
const el = await fixture<SlDialog>(html` <sl-dialog><input /></sl-dialog> `);
const input = el.querySelector('input');
const initialFocusHandler = sinon.spy(event => {
const input = el.querySelector('input')!;
const initialFocusHandler = sinon.spy((event: Event) => {
event.preventDefault();
input.focus();
});
el.addEventListener('sl-initial-focus', initialFocusHandler);
el.show();
void el.show();
await waitUntil(() => initialFocusHandler.calledOnce);

View File

@@ -2,18 +2,16 @@ import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { animateTo, stopAnimations } from '../../internal/animate';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { waitForEvent } from '../../internal/event';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import { HasSlotController } from '../../internal/slot';
import { isPreventScrollSupported } from '../../internal/support';
import Modal from '../../internal/modal';
import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry';
import styles from './dialog.styles';
import '../icon-button/icon-button';
import '~/components/icon-button/icon-button';
import { animateTo, stopAnimations } from '~/internal/animate';
import { emit, waitForEvent } from '~/internal/event';
import Modal from '~/internal/modal';
import { lockBodyScrolling, unlockBodyScrolling } from '~/internal/scroll';
import { HasSlotController } from '~/internal/slot';
import { isPreventScrollSupported } from '~/internal/support';
import { watch } from '~/internal/watch';
import { setDefaultAnimation, getAnimation } from '~/utilities/animation-registry';
const hasPreventScroll = isPreventScrollSupported();
@@ -65,7 +63,7 @@ export default class SlDialog extends LitElement {
@query('.dialog__panel') panel: HTMLElement;
@query('.dialog__overlay') overlay: HTMLElement;
private hasSlotController = new HasSlotController(this, 'footer');
private readonly hasSlotController = new HasSlotController(this, 'footer');
private modal: Modal;
private originalTrigger: HTMLElement | null;
@@ -106,7 +104,7 @@ export default class SlDialog extends LitElement {
/** Shows the dialog. */
async show() {
if (this.open) {
return;
return undefined;
}
this.open = true;
@@ -116,7 +114,7 @@ export default class SlDialog extends LitElement {
/** Hides the dialog */
async hide() {
if (!this.open) {
return;
return undefined;
}
this.open = false;
@@ -127,11 +125,11 @@ export default class SlDialog extends LitElement {
const slRequestClose = emit(this, 'sl-request-close', { cancelable: true });
if (slRequestClose.defaultPrevented) {
const animation = getAnimation(this, 'dialog.denyClose');
animateTo(this.panel, animation.keyframes, animation.options);
void animateTo(this.panel, animation.keyframes, animation.options);
return;
}
this.hide();
void this.hide();
}
handleKeyDown(event: KeyboardEvent) {
@@ -197,8 +195,10 @@ export default class SlDialog extends LitElement {
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (trigger && typeof trigger.focus === 'function') {
setTimeout(() => trigger.focus());
if (typeof trigger?.focus === 'function') {
setTimeout(() => {
trigger.focus();
});
}
emit(this, 'sl-after-hide');
@@ -216,7 +216,13 @@ export default class SlDialog extends LitElement {
})}
@keydown=${this.handleKeyDown}
>
<div part="overlay" class="dialog__overlay" @click=${this.requestClose} tabindex="-1"></div>
<div
part="overlay"
class="dialog__overlay"
@click=${this.requestClose}
@keydown=${this.handleKeyDown}
tabindex="-1"
></div>
<div
part="panel"
@@ -232,7 +238,7 @@ export default class SlDialog extends LitElement {
? html`
<header part="header" class="dialog__header">
<span part="title" class="dialog__title" id="title">
<slot name="label"> ${this.label || String.fromCharCode(65279)} </slot>
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
</span>
<sl-icon-button
exportparts="base:close-button"

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,7 +1,7 @@
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { watch } from '../../internal/watch';
import styles from './divider.styles';
import { watch } from '~/internal/watch';
/**
* @since 2.0

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,8 +1,6 @@
// cspell:dictionaries lorem-ipsum
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlDrawer from './drawer';
describe('<sl-drawer>', () => {
@@ -10,7 +8,7 @@ describe('<sl-drawer>', () => {
const el = await fixture<SlDrawer>(html`
<sl-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
expect(base.hidden).to.be.false;
});
@@ -19,7 +17,7 @@ describe('<sl-drawer>', () => {
const el = await fixture<SlDrawer>(
html` <sl-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer> `
);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
expect(base.hidden).to.be.true;
});
@@ -28,13 +26,13 @@ describe('<sl-drawer>', () => {
const el = await fixture<SlDrawer>(html`
<sl-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
void el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
@@ -48,13 +46,13 @@ describe('<sl-drawer>', () => {
const el = await fixture<SlDrawer>(html`
<sl-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
void el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
@@ -68,7 +66,7 @@ describe('<sl-drawer>', () => {
const el = await fixture<SlDrawer>(html`
<sl-drawer>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
@@ -88,7 +86,7 @@ describe('<sl-drawer>', () => {
const el = await fixture<SlDrawer>(html`
<sl-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`);
const base = el.shadowRoot?.querySelector('[part="base"]') as HTMLElement;
const base = el.shadowRoot!.querySelector<HTMLElement>('[part="base"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
@@ -108,9 +106,11 @@ describe('<sl-drawer>', () => {
const el = await fixture<SlDrawer>(html`
<sl-drawer open>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</sl-drawer>
`);
const overlay = el.shadowRoot?.querySelector('[part="overlay"]') as HTMLElement;
const overlay = el.shadowRoot!.querySelector<HTMLElement>('[part="overlay"]')!;
el.addEventListener('sl-request-close', event => event.preventDefault());
el.addEventListener('sl-request-close', event => {
event.preventDefault();
});
overlay.click();
expect(el.open).to.be.true;
@@ -118,14 +118,14 @@ describe('<sl-drawer>', () => {
it('should allow initial focus to be set', async () => {
const el = await fixture<SlDrawer>(html` <sl-drawer><input /></sl-drawer> `);
const input = el.querySelector('input');
const initialFocusHandler = sinon.spy(event => {
const input = el.querySelector<HTMLInputElement>('input')!;
const initialFocusHandler = sinon.spy((event: InputEvent) => {
event.preventDefault();
input.focus();
});
el.addEventListener('sl-initial-focus', initialFocusHandler);
el.show();
void el.show();
await waitUntil(() => initialFocusHandler.calledOnce);

View File

@@ -2,19 +2,17 @@ import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { animateTo, stopAnimations } from '../../internal/animate';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { waitForEvent } from '../../internal/event';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll';
import { HasSlotController } from '../../internal/slot';
import { uppercaseFirstLetter } from '../../internal/string';
import { isPreventScrollSupported } from '../../internal/support';
import Modal from '../../internal/modal';
import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry';
import styles from './drawer.styles';
import '../icon-button/icon-button';
import '~/components/icon-button/icon-button';
import { animateTo, stopAnimations } from '~/internal/animate';
import { emit, waitForEvent } from '~/internal/event';
import Modal from '~/internal/modal';
import { lockBodyScrolling, unlockBodyScrolling } from '~/internal/scroll';
import { HasSlotController } from '~/internal/slot';
import { uppercaseFirstLetter } from '~/internal/string';
import { isPreventScrollSupported } from '~/internal/support';
import { watch } from '~/internal/watch';
import { setDefaultAnimation, getAnimation } from '~/utilities/animation-registry';
const hasPreventScroll = isPreventScrollSupported();
@@ -73,7 +71,7 @@ export default class SlDrawer extends LitElement {
@query('.drawer__panel') panel: HTMLElement;
@query('.drawer__overlay') overlay: HTMLElement;
private hasSlotController = new HasSlotController(this, 'footer');
private readonly hasSlotController = new HasSlotController(this, 'footer');
private modal: Modal;
private originalTrigger: HTMLElement | null;
@@ -123,7 +121,7 @@ export default class SlDrawer extends LitElement {
/** Shows the drawer. */
async show() {
if (this.open) {
return;
return undefined;
}
this.open = true;
@@ -133,7 +131,7 @@ export default class SlDrawer extends LitElement {
/** Hides the drawer */
async hide() {
if (!this.open) {
return;
return undefined;
}
this.open = false;
@@ -144,11 +142,11 @@ export default class SlDrawer extends LitElement {
const slRequestClose = emit(this, 'sl-request-close', { cancelable: true });
if (slRequestClose.defaultPrevented) {
const animation = getAnimation(this, 'drawer.denyClose');
animateTo(this.panel, animation.keyframes, animation.options);
void animateTo(this.panel, animation.keyframes, animation.options);
return;
}
this.hide();
void this.hide();
}
handleKeyDown(event: KeyboardEvent) {
@@ -217,8 +215,10 @@ export default class SlDrawer extends LitElement {
// Restore focus to the original trigger
const trigger = this.originalTrigger;
if (trigger && typeof trigger.focus === 'function') {
setTimeout(() => trigger.focus());
if (typeof trigger?.focus === 'function') {
setTimeout(() => {
trigger.focus();
});
}
emit(this, 'sl-after-hide');
@@ -242,7 +242,13 @@ export default class SlDrawer extends LitElement {
})}
@keydown=${this.handleKeyDown}
>
<div part="overlay" class="drawer__overlay" @click=${this.requestClose} tabindex="-1"></div>
<div
part="overlay"
class="drawer__overlay"
@click=${this.requestClose}
@keydown=${this.handleKeyDown}
tabindex="-1"
></div>
<div
part="panel"
@@ -259,7 +265,7 @@ export default class SlDrawer extends LitElement {
<header part="header" class="drawer__header">
<span part="title" class="drawer__title" id="title">
<!-- If there's no label, use an invisible character to prevent the header from collapsing -->
<slot name="label"> ${this.label || String.fromCharCode(65279)} </slot>
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
</span>
<sl-icon-button
exportparts="base:close-button"

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,7 +1,5 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlDropdown from './dropdown';
describe('<sl-dropdown>', () => {
@@ -16,7 +14,7 @@ describe('<sl-dropdown>', () => {
</sl-menu>
</sl-dropdown>
`);
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part="panel"]')!;
expect(panel.hidden).to.be.false;
});
@@ -32,7 +30,7 @@ describe('<sl-dropdown>', () => {
</sl-menu>
</sl-dropdown>
`);
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part="panel"]')!;
expect(panel.hidden).to.be.true;
});
@@ -48,13 +46,13 @@ describe('<sl-dropdown>', () => {
</sl-menu>
</sl-dropdown>
`);
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('sl-show', showHandler);
el.addEventListener('sl-after-show', afterShowHandler);
el.show();
void el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
@@ -75,13 +73,13 @@ describe('<sl-dropdown>', () => {
</sl-menu>
</sl-dropdown>
`);
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('sl-hide', hideHandler);
el.addEventListener('sl-after-hide', afterHideHandler);
el.hide();
void el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
@@ -102,7 +100,7 @@ describe('<sl-dropdown>', () => {
</sl-menu>
</sl-dropdown>
`);
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
@@ -129,7 +127,7 @@ describe('<sl-dropdown>', () => {
</sl-menu>
</sl-dropdown>
`);
const panel = el.shadowRoot?.querySelector('[part="panel"]') as HTMLElement;
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();

View File

@@ -1,17 +1,17 @@
import type { Instance as PopperInstance } from '@popperjs/core/dist/esm';
import { createPopper } from '@popperjs/core/dist/esm';
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { Instance as PopperInstance, createPopper } from '@popperjs/core/dist/esm';
import { animateTo, stopAnimations } from '../../internal/animate';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { waitForEvent } from '../../internal/event';
import { scrollIntoView } from '../../internal/scroll';
import { getTabbableBoundary } from '../../internal/tabbable';
import { setDefaultAnimation, getAnimation } from '../../utilities/animation-registry';
import type SlMenu from '../menu/menu';
import type SlMenuItem from '../menu-item/menu-item';
import styles from './dropdown.styles';
import type SlMenuItem from '~/components/menu-item/menu-item';
import type SlMenu from '~/components/menu/menu';
import { animateTo, stopAnimations } from '~/internal/animate';
import { emit, waitForEvent } from '~/internal/event';
import { scrollIntoView } from '~/internal/scroll';
import { getTabbableBoundary } from '~/internal/tabbable';
import { watch } from '~/internal/watch';
import { setDefaultAnimation, getAnimation } from '~/utilities/animation-registry';
/**
* @since 2.0
@@ -40,7 +40,7 @@ export default class SlDropdown extends LitElement {
@query('.dropdown__panel') panel: HTMLElement;
@query('.dropdown__positioner') positioner: HTMLElement;
private popover: PopperInstance;
private popover?: PopperInstance;
/** Indicates whether or not the dropdown is open. You can use this in lieu of the show/hide methods. */
@property({ type: Boolean, reflect: true }) open = false;
@@ -73,7 +73,7 @@ export default class SlDropdown extends LitElement {
@property({ attribute: 'stay-open-on-select', type: Boolean, reflect: true }) stayOpenOnSelect = false;
/** The dropdown will close when the user interacts outside of this element (e.g. clicking). */
@property({ attribute: false }) containingElement: HTMLElement;
@property({ attribute: false }) containingElement?: HTMLElement;
/** The distance in pixels from which to offset the panel away from its trigger. */
@property({ type: Number }) distance = 0;
@@ -94,12 +94,12 @@ export default class SlDropdown extends LitElement {
this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this);
if (!this.containingElement) {
if (typeof this.containingElement === 'undefined') {
this.containingElement = this;
}
// Create the popover after render
this.updateComplete.then(() => {
void this.updateComplete.then(() => {
this.popover = createPopper(this.trigger, this.positioner, {
placement: this.placement,
strategy: this.hoist ? 'fixed' : 'absolute',
@@ -127,30 +127,30 @@ export default class SlDropdown extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this.hide();
void void this.hide();
if (this.popover) {
this.popover.destroy();
}
this.popover?.destroy();
}
focusOnTrigger() {
const slot = this.trigger.querySelector('slot')!;
const trigger = slot.assignedElements({ flatten: true })[0] as any;
if (trigger && typeof trigger.focus === 'function') {
const trigger = slot.assignedElements({ flatten: true })[0] as HTMLElement | undefined;
if (typeof trigger?.focus === 'function') {
trigger.focus();
}
}
getMenu() {
const slot = this.panel.querySelector('slot')!;
return slot.assignedElements({ flatten: true }).filter(el => el.tagName.toLowerCase() === 'sl-menu')[0] as SlMenu;
return slot.assignedElements({ flatten: true }).find(el => el.tagName.toLowerCase() === 'sl-menu') as
| SlMenu
| undefined;
}
handleDocumentKeyDown(event: KeyboardEvent) {
// Close when escape is pressed
if (event.key === 'Escape') {
this.hide();
void this.hide();
this.focusOnTrigger();
return;
}
@@ -160,7 +160,7 @@ export default class SlDropdown extends LitElement {
// Tabbing within an open menu should close the dropdown and refocus the trigger
if (this.open && document.activeElement?.tagName.toLowerCase() === 'sl-menu-item') {
event.preventDefault();
this.hide();
void this.hide();
this.focusOnTrigger();
return;
}
@@ -171,13 +171,15 @@ export default class SlDropdown extends LitElement {
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
setTimeout(() => {
const activeElement =
this.containingElement.getRootNode() instanceof ShadowRoot
this.containingElement?.getRootNode() instanceof ShadowRoot
? document.activeElement?.shadowRoot?.activeElement
: document.activeElement;
if (activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement) {
this.hide();
return;
if (
typeof this.containingElement === 'undefined' ||
activeElement?.closest(this.containingElement.tagName.toLowerCase()) !== this.containingElement
) {
void this.hide();
}
});
}
@@ -185,10 +187,9 @@ export default class SlDropdown extends LitElement {
handleDocumentMouseDown(event: MouseEvent) {
// Close when clicking outside of the containing element
const path = event.composedPath() as Array<EventTarget>;
if (!path.includes(this.containingElement)) {
this.hide();
return;
const path = event.composedPath();
if (typeof this.containingElement !== 'undefined' && !path.includes(this.containingElement)) {
void this.hide();
}
}
@@ -202,7 +203,7 @@ export default class SlDropdown extends LitElement {
// Hide the dropdown when a menu item is selected
if (!this.stayOpenOnSelect && target.tagName.toLowerCase() === 'sl-menu') {
this.hide();
void this.hide();
this.focusOnTrigger();
}
}
@@ -212,42 +213,44 @@ export default class SlDropdown extends LitElement {
@watch('placement')
@watch('skidding')
handlePopoverOptionsChange() {
if (this.popover) {
this.popover.setOptions({
placement: this.placement,
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
void this.popover?.setOptions({
placement: this.placement,
strategy: this.hoist ? 'fixed' : 'absolute',
modifiers: [
{
name: 'flip',
options: {
boundary: 'viewport'
}
]
});
}
},
{
name: 'offset',
options: {
offset: [this.skidding, this.distance]
}
}
]
});
}
handleTriggerClick() {
this.open ? this.hide() : this.show();
if (this.open) {
void this.hide();
} else {
void this.show();
}
}
handleTriggerKeyDown(event: KeyboardEvent) {
const menu = this.getMenu();
const menuItems = menu ? ([...menu.querySelectorAll('sl-menu-item')] as SlMenuItem[]) : [];
const menuItems = typeof menu !== 'undefined' ? ([...menu.querySelectorAll('sl-menu-item')] as SlMenuItem[]) : [];
const firstMenuItem = menuItems[0];
const lastMenuItem = menuItems[menuItems.length - 1];
// Close when escape or tab is pressed
if (event.key === 'Escape') {
this.focusOnTrigger();
this.hide();
void this.hide();
return;
}
@@ -255,7 +258,7 @@ export default class SlDropdown extends LitElement {
// key again to hide the menu in case they don't want to make a selection.
if ([' ', 'Enter'].includes(event.key)) {
event.preventDefault();
this.open ? this.hide() : this.show();
this.handleTriggerClick();
return;
}
@@ -267,19 +270,18 @@ export default class SlDropdown extends LitElement {
// Show the menu if it's not already open
if (!this.open) {
this.show();
void this.show();
}
// Focus on a menu item
if (event.key === 'ArrowDown' && firstMenuItem) {
const menu = this.getMenu();
menu.setCurrentItem(firstMenuItem);
if (event.key === 'ArrowDown' && typeof firstMenuItem !== 'undefined') {
menu!.setCurrentItem(firstMenuItem);
firstMenuItem.focus();
return;
}
if (event.key === 'ArrowUp' && lastMenuItem) {
menu.setCurrentItem(lastMenuItem);
if (event.key === 'ArrowUp' && typeof lastMenuItem !== 'undefined') {
menu!.setCurrentItem(lastMenuItem);
lastMenuItem.focus();
return;
}
@@ -287,9 +289,8 @@ export default class SlDropdown extends LitElement {
// Other keys bring focus to the menu and initiate type-to-select behavior
const ignoredKeys = ['Tab', 'Shift', 'Meta', 'Ctrl', 'Alt'];
if (this.open && menu && !ignoredKeys.includes(event.key)) {
menu.typeToSelect(event.key);
return;
if (this.open && !ignoredKeys.includes(event.key)) {
menu?.typeToSelect(event.key);
}
}
@@ -315,22 +316,20 @@ export default class SlDropdown extends LitElement {
// To determine this, we assume the first tabbable element in the trigger slot is the "accessible trigger."
//
updateAccessibleTrigger() {
if (this.trigger) {
const slot = this.trigger.querySelector('slot') as HTMLSlotElement;
const assignedElements = slot.assignedElements({ flatten: true }) as HTMLElement[];
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
const slot = this.trigger.querySelector('slot')!;
const assignedElements = slot.assignedElements({ flatten: true }) as HTMLElement[];
const accessibleTrigger = assignedElements.find(el => getTabbableBoundary(el).start);
if (accessibleTrigger) {
accessibleTrigger.setAttribute('aria-haspopup', 'true');
accessibleTrigger.setAttribute('aria-expanded', this.open ? 'true' : 'false');
}
if (typeof accessibleTrigger !== 'undefined') {
accessibleTrigger.setAttribute('aria-haspopup', 'true');
accessibleTrigger.setAttribute('aria-expanded', this.open ? 'true' : 'false');
}
}
/** Shows the dropdown panel. */
async show() {
if (this.open) {
return;
return undefined;
}
this.open = true;
@@ -340,7 +339,7 @@ export default class SlDropdown extends LitElement {
/** Hides the dropdown panel */
async hide() {
if (!this.open) {
return;
return undefined;
}
this.open = false;
@@ -356,7 +355,7 @@ export default class SlDropdown extends LitElement {
return;
}
this.popover.update();
void this.popover?.update();
}
@watch('open', { waitUntilFirstUpdate: true })
@@ -376,7 +375,7 @@ export default class SlDropdown extends LitElement {
document.addEventListener('mousedown', this.handleDocumentMouseDown);
await stopAnimations(this);
this.popover.update();
void this.popover?.update();
this.panel.hidden = false;
const { keyframes, options } = getAnimation(this, 'dropdown.show');
await animateTo(this.panel, keyframes, options);

View File

@@ -1,7 +1,7 @@
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { formatBytes } from '../../internal/number';
import { LocalizeController } from '../../utilities/localize';
import { formatBytes } from '~/internal/number';
import { LocalizeController } from '~/utilities/localize';
/**
* @since 2.0
@@ -9,7 +9,7 @@ import { LocalizeController } from '../../utilities/localize';
*/
@customElement('sl-format-bytes')
export default class SlFormatBytes extends LitElement {
private localize = new LocalizeController(this);
private readonly localize = new LocalizeController(this);
/** The number to format in bytes. */
@property({ type: Number }) value = 0;

View File

@@ -1,6 +1,6 @@
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { LocalizeController } from '../../utilities/localize';
import { LocalizeController } from '~/utilities/localize';
/**
* @since 2.0
@@ -8,7 +8,7 @@ import { LocalizeController } from '../../utilities/localize';
*/
@customElement('sl-format-date')
export default class SlFormatDate extends LitElement {
private localize = new LocalizeController(this);
private readonly localize = new LocalizeController(this);
/** The date/time to format. If not set, the current date and time will be used. */
@property() date: Date | string = new Date();
@@ -55,7 +55,7 @@ export default class SlFormatDate extends LitElement {
// Check for an invalid date
if (isNaN(date.getMilliseconds())) {
return;
return undefined;
}
return this.localize.date(date, {

View File

@@ -1,6 +1,6 @@
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { LocalizeController } from '../../utilities/localize';
import { LocalizeController } from '~/utilities/localize';
/**
* @since 2.0
@@ -8,7 +8,7 @@ import { LocalizeController } from '../../utilities/localize';
*/
@customElement('sl-format-number')
export default class SlFormatNumber extends LitElement {
private localize = new LocalizeController(this);
private readonly localize = new LocalizeController(this);
/** The number to format. */
@property({ type: Number }) value = 0;

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -3,8 +3,7 @@ import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import styles from './icon-button.styles';
import '../icon/icon';
import '~/components/icon/icon';
/**
* @since 2.0
@@ -21,22 +20,22 @@ export default class SlIconButton extends LitElement {
@query('button') button: HTMLButtonElement | HTMLLinkElement;
/** The name of the icon to draw. */
@property() name: string;
@property() name?: string;
/** The name of a registered custom icon library. */
@property() library: string;
@property() library?: string;
/** An external URL of an SVG file. */
@property() src: string;
@property() src?: string;
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href: string;
@property() href?: string;
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target: '_blank' | '_parent' | '_self' | '_top';
@property() target?: '_blank' | '_parent' | '_self' | '_top';
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
@property() download: string;
@property() download?: string;
/**
* A description that gets read by screen readers and other assistive devices. For optimal accessibility, you should
@@ -48,7 +47,7 @@ export default class SlIconButton extends LitElement {
@property({ type: Boolean, reflect: true }) disabled = false;
render() {
const isLink = this.href ? true : false;
const isLink = typeof this.href !== 'undefined';
const interior = html`
<sl-icon
@@ -67,7 +66,7 @@ export default class SlIconButton extends LitElement {
href=${ifDefined(this.href)}
target=${ifDefined(this.target)}
download=${ifDefined(this.download)}
rel=${ifDefined(this.target ? 'noreferrer noopener' : undefined)}
rel=${ifDefined(typeof this.target !== 'undefined' ? 'noreferrer noopener' : undefined)}
role="button"
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-label="${this.label}"

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -2,11 +2,11 @@ import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import styles from './icon.styles';
import { getIconLibrary, watchIcon, unwatchIcon } from './library';
import { requestIcon } from './request';
import styles from './icon.styles';
import { emit } from '~/internal/event';
import { watch } from '~/internal/watch';
const parser = new DOMParser();
@@ -26,13 +26,17 @@ export default class SlIcon extends LitElement {
@state() private svg = '';
/** The name of the icon to draw. */
@property() name: string;
@property() name?: string;
/** An external URL of an SVG file. */
@property() src: string;
/**
* An external URL of an SVG file.
*
* WARNING: Be sure you trust the content you are including as it will be executed as code and can result in XSS attacks.
*/
@property() src?: string;
/** An alternate description to use for accessibility. If omitted, the icon will be ignored by assistive devices. */
@property() label: string;
@property() label = '';
/** The name of a registered custom icon library. */
@property() library = 'default';
@@ -43,7 +47,7 @@ export default class SlIcon extends LitElement {
}
firstUpdated() {
this.setIcon();
void this.setIcon();
}
disconnectedCallback() {
@@ -51,18 +55,17 @@ export default class SlIcon extends LitElement {
unwatchIcon(this);
}
private getUrl(): string {
private getUrl() {
const library = getIconLibrary(this.library);
if (this.name && library) {
if (typeof this.name !== 'undefined' && typeof library !== 'undefined') {
return library.resolver(this.name);
} else {
return this.src;
}
return this.src;
}
/** @internal Fetches the icon and redraws it. Used to handle library registrations. */
redraw() {
this.setIcon();
void this.setIcon();
}
@watch('name')
@@ -71,7 +74,7 @@ export default class SlIcon extends LitElement {
async setIcon() {
const library = getIconLibrary(this.library);
const url = this.getUrl();
if (url) {
if (typeof url !== 'undefined' && url.length > 0) {
try {
const file = await requestIcon(url)!;
if (url !== this.getUrl()) {
@@ -81,10 +84,8 @@ export default class SlIcon extends LitElement {
const doc = parser.parseFromString(file.svg, 'text/html');
const svgEl = doc.body.querySelector('svg');
if (svgEl) {
if (library && library.mutator) {
library.mutator(svgEl);
}
if (svgEl !== null) {
library?.mutator?.(svgEl);
this.svg = svgEl.outerHTML;
emit(this, 'sl-load');
@@ -99,14 +100,14 @@ export default class SlIcon extends LitElement {
} catch {
emit(this, 'sl-error', { detail: { status: -1 } });
}
} else if (this.svg) {
} else if (this.svg.length > 0) {
// If we can't resolve a URL and an icon was previously set, remove it
this.svg = '';
}
}
handleChange() {
this.setIcon();
void this.setIcon();
}
render() {

View File

@@ -1,5 +1,5 @@
import { getBasePath } from '../../utilities/base-path';
import type { IconLibrary } from './library';
import { getBasePath } from '~/utilities/base-path';
const library: IconLibrary = {
name: 'default',

View File

@@ -86,11 +86,10 @@ const icons = {
const systemLibrary: IconLibrary = {
name: 'system',
resolver: (name: keyof typeof icons) => {
if (icons[name]) {
if (name in icons) {
return `data:image/svg+xml,${encodeURIComponent(icons[name])}`;
} else {
return '';
}
return '';
}
};

View File

@@ -1,6 +1,6 @@
import defaultLibrary from './library.default';
import systemLibrary from './library.system';
import type SlIcon from '../icon/icon';
import type SlIcon from '~/components/icon/icon';
export type IconLibraryResolver = (name: string) => string;
export type IconLibraryMutator = (svg: SVGElement) => void;
@@ -22,7 +22,7 @@ export function unwatchIcon(icon: SlIcon) {
}
export function getIconLibrary(name?: string) {
return registry.filter(lib => lib.name === name)[0];
return registry.find(lib => lib.name === name);
}
export function registerIconLibrary(
@@ -37,7 +37,7 @@ export function registerIconLibrary(
});
// Redraw watched icons
watchedIcons.map(icon => {
watchedIcons.forEach(icon => {
if (icon.library === name) {
icon.redraw();
}

View File

@@ -1,36 +1,42 @@
interface IconFile {
import { requestInclude } from '~/components/include/request';
type IconFile =
| {
ok: true;
status: number;
svg: string;
}
| {
ok: false;
status: number;
svg: null;
};
interface IconFileUnknown {
ok: boolean;
status: number;
svg: string;
svg: string | null;
}
const iconFiles = new Map<string, Promise<IconFile>>();
const iconFiles = new Map<string, IconFile>();
export const requestIcon = (url: string) => {
export async function requestIcon(url: string): Promise<IconFile> {
if (iconFiles.has(url)) {
return iconFiles.get(url);
} else {
const request = fetch(url).then(async response => {
if (response.ok) {
const div = document.createElement('div');
div.innerHTML = await response.text();
const svg = div.firstElementChild;
return {
ok: response.ok,
status: response.status,
svg: svg && svg.tagName.toLowerCase() === 'svg' ? svg.outerHTML : ''
};
} else {
return {
ok: response.ok,
status: response.status,
svg: null
};
}
}) as Promise<IconFile>;
iconFiles.set(url, request);
return request;
return iconFiles.get(url)!;
}
};
const fileData = await requestInclude(url);
const iconFileData: IconFileUnknown = {
ok: fileData.ok,
status: fileData.status,
svg: null
};
if (fileData.ok) {
const div = document.createElement('div');
div.innerHTML = fileData.html;
const svg = div.firstElementChild;
iconFileData.svg = svg?.tagName.toLowerCase() === 'svg' ? svg.outerHTML : '';
}
iconFiles.set(url, iconFileData as IconFile);
return iconFileData as IconFile;
}

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,12 +1,13 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { clamp } from '../../internal/math';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import styles from './image-comparer.styles';
import '../icon/icon';
import '~/components/icon/icon';
import { autoIncrement } from '~/internal/autoIncrement';
import { drag } from '~/internal/drag';
import { emit } from '~/internal/event';
import { clamp } from '~/internal/math';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -36,42 +37,19 @@ export default class SlImageComparer extends LitElement {
@query('.image-comparer') base: HTMLElement;
@query('.image-comparer__handle') handle: HTMLElement;
private readonly attrId = autoIncrement();
private readonly containerId = `comparer-container-${this.attrId}`;
/** The position of the divider as a percentage. */
@property({ type: Number, reflect: true }) position = 50;
handleDrag(event: any) {
handleDrag(event: Event) {
const { width } = this.base.getBoundingClientRect();
function drag(event: any, container: HTMLElement, onMove: (x: number) => void) {
const move = (event: any) => {
const { left } = container.getBoundingClientRect();
const { pageXOffset } = container.ownerDocument.defaultView!;
const offsetX = left + pageXOffset;
const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX;
onMove(x);
};
// Move on init
move(event);
const stop = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('touchmove', move);
document.removeEventListener('mouseup', stop);
document.removeEventListener('touchend', stop);
};
document.addEventListener('mousemove', move);
document.addEventListener('touchmove', move);
document.addEventListener('mouseup', stop);
document.addEventListener('touchend', stop);
}
event.preventDefault();
drag(event, this.base, x => {
this.position = Number(clamp((x / width) * 100, 0, 100).toFixed(2));
drag(this.base, x => {
this.position = parseFloat(clamp((x / width) * 100, 0, 100).toFixed(2));
});
}
@@ -82,10 +60,18 @@ export default class SlImageComparer extends LitElement {
event.preventDefault();
if (event.key === 'ArrowLeft') newPosition = newPosition - incr;
if (event.key === 'ArrowRight') newPosition = newPosition + incr;
if (event.key === 'Home') newPosition = 0;
if (event.key === 'End') newPosition = 100;
if (event.key === 'ArrowLeft') {
newPosition -= incr;
}
if (event.key === 'ArrowRight') {
newPosition += incr;
}
if (event.key === 'Home') {
newPosition = 0;
}
if (event.key === 'End') {
newPosition = 100;
}
newPosition = clamp(newPosition, 0, 100);
this.position = newPosition;
@@ -99,7 +85,7 @@ export default class SlImageComparer extends LitElement {
render() {
return html`
<div part="base" class="image-comparer" @keydown=${this.handleKeyDown}>
<div part="base" class="image-comparer" @keydown=${this.handleKeyDown} id=${this.containerId}>
<div class="image-comparer__image">
<div part="before" class="image-comparer__before">
<slot name="before"></slot>
@@ -117,7 +103,7 @@ export default class SlImageComparer extends LitElement {
<div
part="divider"
class="image-comparer__divider"
style=${styleMap({ left: this.position + '%' })}
style=${styleMap({ left: `${this.position}%` })}
@mousedown=${this.handleDrag}
@touchstart=${this.handleDrag}
>
@@ -128,6 +114,7 @@ export default class SlImageComparer extends LitElement {
aria-valuenow=${this.position}
aria-valuemin="0"
aria-valuemax="100"
aria-controls=${this.containerId}
tabindex="0"
>
<slot name="handle-icon">

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,7 +1,5 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlInclude from './include';
const stubbedFetchResponse: Response = {
@@ -22,6 +20,14 @@ const stubbedFetchResponse: Response = {
clone: sinon.fake()
};
function delayResolve(resolveValue: string) {
return new Promise<string>(resolve => {
setTimeout(() => {
resolve(resolveValue);
});
});
}
describe('<sl-include>', () => {
afterEach(() => {
sinon.verifyAndRestore();
@@ -32,7 +38,7 @@ describe('<sl-include>', () => {
...stubbedFetchResponse,
ok: true,
status: 200,
text: () => Promise.resolve('"id": 1')
text: () => delayResolve('"id": 1')
});
const el = await fixture<SlInclude>(html` <sl-include src="/found"></sl-include> `);
const loadHandler = sinon.spy();
@@ -49,7 +55,7 @@ describe('<sl-include>', () => {
...stubbedFetchResponse,
ok: false,
status: 404,
text: () => Promise.resolve('{}')
text: () => delayResolve('{}')
});
const el = await fixture<SlInclude>(html` <sl-include src="/not-found"></sl-include> `);
const loadHandler = sinon.spy();

View File

@@ -1,9 +1,9 @@
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { requestInclude } from './request';
import styles from './include.styles';
import { requestInclude } from './request';
import { emit } from '~/internal/event';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -16,7 +16,11 @@ import styles from './include.styles';
export default class SlInclude extends LitElement {
static styles = styles;
/** The location of the HTML file to include. */
/**
* The location of the HTML file to include.
*
* WARNING: Be sure you trust the content you are including as it will be executed as code and can result in XSS attacks.
*/
@property() src: string;
/** The fetch mode to use. */
@@ -31,7 +35,9 @@ export default class SlInclude extends LitElement {
executeScript(script: HTMLScriptElement) {
// Create a copy of the script and swap it out so the browser executes it
const newScript = document.createElement('script');
[...script.attributes].map(attr => newScript.setAttribute(attr.name, attr.value));
[...script.attributes].forEach(attr => {
newScript.setAttribute(attr.name, attr.value);
});
newScript.textContent = script.textContent;
script.parentNode!.replaceChild(newScript, script);
}
@@ -47,7 +53,7 @@ export default class SlInclude extends LitElement {
return;
}
if (!file) {
if (typeof file === 'undefined') {
return;
}
@@ -59,7 +65,9 @@ export default class SlInclude extends LitElement {
this.innerHTML = file.html;
if (this.allowScripts) {
[...this.querySelectorAll('script')].map(script => this.executeScript(script));
[...this.querySelectorAll('script')].forEach(script => {
this.executeScript(script);
});
}
emit(this, 'sl-load');

View File

@@ -6,18 +6,17 @@ interface IncludeFile {
const includeFiles = new Map<string, Promise<IncludeFile>>();
export const requestInclude = async (src: string, mode: 'cors' | 'no-cors' | 'same-origin' = 'cors') => {
export function requestInclude(src: string, mode: 'cors' | 'no-cors' | 'same-origin' = 'cors'): Promise<IncludeFile> {
if (includeFiles.has(src)) {
return includeFiles.get(src);
} else {
const request = fetch(src, { mode: mode }).then(async response => {
return {
ok: response.ok,
status: response.status,
html: await response.text()
};
});
includeFiles.set(src, request);
return request;
return includeFiles.get(src)!;
}
};
const fileDataPromise = fetch(src, { mode: mode }).then(async response => {
return {
ok: response.ok,
status: response.status,
html: await response.text()
};
});
includeFiles.set(src, fileDataPromise);
return fileDataPromise;
}

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import formControlStyles from '../../styles/form-control.styles';
import componentStyles from '~/styles/component.styles';
import formControlStyles from '~/styles/form-control.styles';
export default css`
${componentStyles}

View File

@@ -1,13 +1,10 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import { expect, fixture, html } from '@open-wc/testing';
import type SlInput from './input';
describe('<sl-input>', () => {
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlInput>(html` <sl-input disabled></sl-input> `);
const input = el.shadowRoot?.querySelector('[part="input"]') as HTMLInputElement;
const input = el.shadowRoot!.querySelector<HTMLInputElement>('[part="input"]')!;
expect(input.disabled).to.be.true;
});

View File

@@ -1,17 +1,15 @@
import { LitElement, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { FormSubmitController, getLabelledBy, renderFormControl } from '../../internal/form-control';
import { HasSlotController } from '../../internal/slot';
import styles from './input.styles';
import '../icon/icon';
let id = 0;
import '~/components/icon/icon';
import { autoIncrement } from '~/internal/autoIncrement';
import { emit } from '~/internal/event';
import { FormSubmitController, getLabelledBy, renderFormControl } from '~/internal/form-control';
import { HasSlotController } from '~/internal/slot';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -49,12 +47,13 @@ export default class SlInput extends LitElement {
@query('.input__control') input: HTMLInputElement;
// @ts-ignore
private formSubmitController = new FormSubmitController(this);
private hasSlotController = new HasSlotController(this, 'help-text', 'label');
private inputId = `input-${++id}`;
private helpTextId = `input-help-text-${id}`;
private labelId = `input-label-${id}`;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly attrId = autoIncrement();
private readonly inputId = `input-${this.attrId}`;
private readonly helpTextId = `input-help-text-${this.attrId}`;
private readonly labelId = `input-label-${this.attrId}`;
@state() private hasFocus = false;
@state() private isPasswordVisible = false;
@@ -79,7 +78,7 @@ export default class SlInput extends LitElement {
@property({ type: Boolean, reflect: true }) pill = false;
/** The input's label. Alternatively, you can use the label slot. */
@property() label: string;
@property() label = '';
/** The input's help text. Alternatively, you can use the help-text slot. */
@property({ attribute: 'help-text' }) helpText = '';
@@ -146,7 +145,7 @@ export default class SlInput extends LitElement {
/** Gets or sets the current value as a `Date` object. Only valid when `type` is `date`. */
get valueAsDate() {
return this.input.valueAsDate as Date;
return this.input.valueAsDate!;
}
set valueAsDate(newValue: Date) {
@@ -156,7 +155,7 @@ export default class SlInput extends LitElement {
/** Gets or sets the current value as a number. */
get valueAsNumber() {
return this.input.valueAsNumber as number;
return this.input.valueAsNumber;
}
set valueAsNumber(newValue: number) {
@@ -180,7 +179,7 @@ export default class SlInput extends LitElement {
/** Selects all the text in the input. */
select() {
return this.input.select();
this.input.select();
}
/** Sets the start and end positions of the text selection (0-based). */
@@ -189,7 +188,7 @@ export default class SlInput extends LitElement {
selectionEnd: number,
selectionDirection: 'forward' | 'backward' | 'none' = 'none'
) {
return this.input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
this.input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
}
/** Replaces a range of text with a new string. */
@@ -239,13 +238,11 @@ export default class SlInput extends LitElement {
event.stopPropagation();
}
@watch('disabled')
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.input) {
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
@@ -266,11 +263,9 @@ export default class SlInput extends LitElement {
this.isPasswordVisible = !this.isPasswordVisible;
}
@watch('value')
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
if (this.input) {
this.invalid = !this.input.checkValidity();
}
this.invalid = !this.input.checkValidity();
}
render() {
@@ -306,7 +301,7 @@ export default class SlInput extends LitElement {
'input--filled': this.filled,
'input--disabled': this.disabled,
'input--focused': this.hasFocus,
'input--empty': this.value?.length === 0,
'input--empty': this.value.length === 0,
'input--invalid': this.invalid
})}
>
@@ -355,7 +350,7 @@ export default class SlInput extends LitElement {
@blur=${this.handleBlur}
/>
${this.clearable && this.value?.length > 0
${this.clearable && this.value.length > 0
? html`
<button
part="clear-button"

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,10 +1,9 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { watch } from '../../internal/watch';
import styles from './menu-item.styles';
import '../icon/icon';
import '~/components/icon/icon';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -43,12 +42,12 @@ export default class SlMenuItem extends LitElement {
@watch('checked')
handleCheckedChange() {
this.setAttribute('aria-checked', String(this.checked));
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
}
@watch('disabled')
handleDisabledChange() {
this.setAttribute('aria-disabled', String(this.disabled));
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
render() {

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,10 +1,14 @@
import { LitElement, html } from 'lit';
import { customElement, query } from 'lit/decorators.js';
import { emit } from '../../internal/event';
import { getTextContent } from '../../internal/slot';
import { hasFocusVisible } from '../../internal/focus-visible';
import type SlMenuItem from '../menu-item/menu-item';
import styles from './menu.styles';
import type SlMenuItem from '~/components/menu-item/menu-item';
import { emit } from '~/internal/event';
import { hasFocusVisible } from '~/internal/focus-visible';
import { getTextContent } from '~/internal/slot';
export interface MenuSelectEventDetail {
item: SlMenuItem;
}
/**
* @since 2.0
@@ -24,7 +28,7 @@ export default class SlMenu extends LitElement {
@query('slot') defaultSlot: HTMLSlotElement;
private typeToSelectString = '';
private typeToSelectTimeout: any;
private typeToSelectTimeout: NodeJS.Timeout;
firstUpdated() {
this.setAttribute('role', 'menu');
@@ -36,7 +40,7 @@ export default class SlMenu extends LitElement {
return false;
}
if (!options?.includeDisabled && (el as SlMenuItem).disabled) {
if (!options.includeDisabled && (el as SlMenuItem).disabled) {
return false;
}
@@ -58,10 +62,12 @@ export default class SlMenu extends LitElement {
*/
setCurrentItem(item: SlMenuItem) {
const items = this.getAllItems({ includeDisabled: false });
let activeItem = item.disabled ? items[0] : item;
const activeItem = item.disabled ? items[0] : item;
// Update tab indexes
items.map(i => i.setAttribute('tabindex', i === activeItem ? '0' : '-1'));
items.forEach(i => {
i.setAttribute('tabindex', i === activeItem ? '0' : '-1');
});
}
/**
@@ -78,13 +84,15 @@ export default class SlMenu extends LitElement {
// Restore focus in browsers that don't support :focus-visible when using the keyboard
if (!hasFocusVisible) {
items.map(item => item.classList.remove('sl-focus-invisible'));
items.forEach(item => {
item.classList.remove('sl-focus-invisible');
});
}
for (const item of items) {
const slot = item.shadowRoot!.querySelector('slot:not([name])') as HTMLSlotElement;
const slot = item.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
const label = getTextContent(slot).toLowerCase().trim();
if (label.substring(0, this.typeToSelectString.length) === this.typeToSelectString) {
if (label.startsWith(this.typeToSelectString)) {
this.setCurrentItem(item);
// Set focus here to force the browser to show :focus-visible styles
@@ -96,9 +104,9 @@ export default class SlMenu extends LitElement {
handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const item = target.closest('sl-menu-item') as SlMenuItem;
const item = target.closest('sl-menu-item');
if (item && !item.disabled) {
if (item?.disabled === false) {
emit(this, 'sl-select', { detail: { item } });
}
}
@@ -107,7 +115,9 @@ export default class SlMenu extends LitElement {
// Restore focus in browsers that don't support :focus-visible when using the keyboard
if (!hasFocusVisible) {
const items = this.getAllItems();
items.map(item => item.classList.remove('sl-focus-invisible'));
items.forEach(item => {
item.classList.remove('sl-focus-invisible');
});
}
}
@@ -117,10 +127,8 @@ export default class SlMenu extends LitElement {
const item = this.getCurrentItem();
event.preventDefault();
if (item) {
// Simulate a click to support @click handlers on menu items that also work with the keyboard
item.click();
}
// Simulate a click to support @click handlers on menu items that also work with the keyboard
item?.click();
}
// Prevent scrolling when space is pressed
@@ -132,9 +140,9 @@ export default class SlMenu extends LitElement {
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
const items = this.getAllItems({ includeDisabled: false });
const activeItem = this.getCurrentItem();
let index = activeItem ? items.indexOf(activeItem) : 0;
let index = typeof activeItem !== 'undefined' ? items.indexOf(activeItem) : 0;
if (items.length) {
if (items.length > 0) {
event.preventDefault();
if (event.key === 'ArrowDown') {
@@ -147,8 +155,12 @@ export default class SlMenu extends LitElement {
index = items.length - 1;
}
if (index < 0) index = 0;
if (index > items.length - 1) index = items.length - 1;
if (index < 0) {
index = 0;
}
if (index > items.length - 1) {
index = items.length - 1;
}
this.setCurrentItem(items[index]);
items[index].focus();
@@ -177,7 +189,7 @@ export default class SlMenu extends LitElement {
const items = this.getAllItems({ includeDisabled: false });
// Reset the roving tab index when the slotted items change
if (items.length) {
if (items.length > 0) {
this.setCurrentItem(items[0]);
}
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,8 +1,4 @@
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
// import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlMutationObserver from './mutation-observer';
import { expect, fixture, html } from '@open-wc/testing';
describe('<sl-mutation-observer>', () => {
it('should render a component', async () => {

View File

@@ -1,8 +1,8 @@
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import styles from './mutation-observer.styles';
import { emit } from '~/internal/event';
import { watch } from '~/internal/watch';
/**
* @since 2.0

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,12 +1,10 @@
import { expect, fixture, html } from '@open-wc/testing';
import '../../../dist/shoelace.js';
import type SlProgressBar from './progress-bar';
describe('<sl-progress-bar>', () => {
let el: SlProgressBar;
describe('when provided just a value parameter', async () => {
describe('when provided just a value parameter', () => {
before(async () => {
el = await fixture<SlProgressBar>(html`<sl-progress-bar value="25"></sl-progress-bar>`);
});
@@ -16,7 +14,7 @@ describe('<sl-progress-bar>', () => {
});
});
describe('when provided a title, and value parameter', async () => {
describe('when provided a title, and value parameter', () => {
let base: HTMLDivElement;
let indicator: HTMLDivElement;
@@ -24,43 +22,43 @@ describe('<sl-progress-bar>', () => {
el = await fixture<SlProgressBar>(
html`<sl-progress-bar title="Titled Progress Ring" value="25"></sl-progress-bar>`
);
base = el.shadowRoot?.querySelector('[part="base"]') as HTMLDivElement;
indicator = el.shadowRoot?.querySelector('[part="indicator"]') as HTMLDivElement;
base = el.shadowRoot!.querySelector('[part="base"]')!;
indicator = el.shadowRoot!.querySelector('[part="indicator"]')!;
});
it('should render a component that passes accessibility test.', async () => {
await expect(el).to.be.accessible();
});
it('uses the value parameter on the base, as aria-valuenow', async () => {
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', async () => {
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', async () => {
describe('when provided an indeterminate parameter', () => {
let base: HTMLDivElement;
before(async () => {
el = await fixture<SlProgressBar>(
html`<sl-progress-bar title="Titled Progress Ring" indeterminate></sl-progress-bar>`
);
base = el.shadowRoot?.querySelector('[part="base"]') as HTMLDivElement;
base = el.shadowRoot!.querySelector('[part="base"]')!;
});
it('should render a component that passes accessibility test.', async () => {
await expect(el).to.be.accessible();
});
it('should append a progress-bar--indeterminate class to the "base" part.', async () => {
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', async () => {
describe('when provided a ariaLabel, and value parameter', () => {
before(async () => {
el = await fixture<SlProgressBar>(
html`<sl-progress-bar ariaLabel="Labelled Progress Ring" value="25"></sl-progress-bar>`
@@ -72,7 +70,7 @@ describe('<sl-progress-bar>', () => {
});
});
describe('when provided a ariaLabelledBy, and value parameter', async () => {
describe('when provided a ariaLabelledBy, and value parameter', () => {
before(async () => {
el = await fixture<SlProgressBar>(
html`

View File

@@ -1,10 +1,10 @@
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { LocalizeController } from '../../utilities/localize';
import styles from './progress-bar.styles';
import { LocalizeController } from '~/utilities/localize';
/**
* @since 2.0
@@ -24,7 +24,7 @@ import styles from './progress-bar.styles';
@customElement('sl-progress-bar')
export default class SlProgressBar extends LitElement {
static styles = styles;
private localize = new LocalizeController(this);
private readonly localize = new LocalizeController(this);
/** The current progress, 0 to 100. */
@property({ type: Number, reflect: true }) value = 0;
@@ -33,7 +33,7 @@ export default class SlProgressBar extends LitElement {
@property({ type: Boolean, reflect: true }) indeterminate = false;
/** A custom label for the progress bar's aria label. */
@property() label: string;
@property() label = '';
/** The locale to render the component in. */
@property() lang: string;
@@ -48,12 +48,12 @@ export default class SlProgressBar extends LitElement {
})}
role="progressbar"
title=${ifDefined(this.title)}
aria-label=${this.label || this.localize.term('progress')}
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${this.indeterminate ? 0 : this.value}
>
<div part="indicator" class="progress-bar__indicator" style=${styleMap({ width: this.value + '%' })}>
<div part="indicator" class="progress-bar__indicator" style=${styleMap({ width: `${this.value}%` })}>
${!this.indeterminate
? html`
<span part="label" class="progress-bar__label">

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,12 +1,10 @@
import { expect, fixture, html } from '@open-wc/testing';
import '../../../dist/shoelace.js';
import type SlProgressRing from './progress-ring';
describe('<sl-progress-ring>', () => {
let el: SlProgressRing;
describe('when provided just a value parameter', async () => {
describe('when provided just a value parameter', () => {
before(async () => {
el = await fixture<SlProgressRing>(html`<sl-progress-ring value="25"></sl-progress-ring>`);
});
@@ -16,30 +14,30 @@ describe('<sl-progress-ring>', () => {
});
});
describe('when provided a title, and value parameter', async () => {
describe('when provided a title, and value parameter', () => {
let base: HTMLDivElement;
before(async () => {
el = await fixture<SlProgressRing>(
html`<sl-progress-ring title="Titled Progress Ring" value="25"></sl-progress-ring>`
);
base = el.shadowRoot?.querySelector('[part="base"]') as HTMLDivElement;
base = el.shadowRoot!.querySelector('[part="base"]')!;
});
it('should render a component that passes accessibility test.', async () => {
await expect(el).to.be.accessible();
});
it('uses the value parameter on the base, as aria-valuenow', async () => {
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', async () => {
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', async () => {
describe('when provided a ariaLabel, and value parameter', () => {
before(async () => {
el = await fixture<SlProgressRing>(
html`<sl-progress-ring ariaLabel="Labelled Progress Ring" value="25"></sl-progress-ring>`
@@ -51,7 +49,7 @@ describe('<sl-progress-ring>', () => {
});
});
describe('when provided a ariaLabelledBy, and value parameter', async () => {
describe('when provided a ariaLabelledBy, and value parameter', () => {
before(async () => {
el = await fixture<SlProgressRing>(
html`

View File

@@ -1,7 +1,7 @@
import { LitElement, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { LocalizeController } from '../../utilities/localize';
import styles from './progress-ring.styles';
import { LocalizeController } from '~/utilities/localize';
/**
* @since 2.0
@@ -20,7 +20,7 @@ import styles from './progress-ring.styles';
@customElement('sl-progress-ring')
export default class SlProgressRing extends LitElement {
static styles = styles;
private localize = new LocalizeController(this);
private readonly localize = new LocalizeController(this);
@query('.progress-ring__indicator') indicator: SVGCircleElement;
@@ -30,12 +30,12 @@ export default class SlProgressRing extends LitElement {
@property({ type: Number, reflect: true }) value = 0;
/** A custom label for the progress ring's aria label. */
@property() label: string;
@property() label = '';
/** The locale to render the component in. */
@property() lang: string;
updated(changedProps: Map<string, any>) {
updated(changedProps: Map<string, unknown>) {
super.updated(changedProps);
//
@@ -48,7 +48,7 @@ export default class SlProgressRing extends LitElement {
const circumference = 2 * Math.PI * radius;
const offset = circumference - (this.value / 100) * circumference;
this.indicatorOffset = String(offset) + 'px';
this.indicatorOffset = `${offset}px`;
}
}
@@ -58,7 +58,7 @@ export default class SlProgressRing extends LitElement {
part="base"
class="progress-ring"
role="progressbar"
aria-label=${this.label || this.localize.term('progress')}
aria-label=${this.label.length > 0 ? this.label : this.localize.term('progress')}
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${this.value}"

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,9 +1,9 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { watch } from '../../internal/watch';
import QrCreator from 'qr-creator';
import styles from './qr-code.styles';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -77,7 +77,7 @@ export default class SlQrCode extends LitElement {
height: `${this.size}px`
})}
>
<canvas role="img" aria-label=${this.label || this.value}></canvas>
<canvas role="img" aria-label=${this.label.length > 0 ? this.label : this.value}></canvas>
</div>
`;
}

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,8 +1,8 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import type SlRadio from '../radio/radio';
import styles from './radio-group.styles';
import type SlRadio from '~/components/radio/radio';
/**
* @since 2.0
@@ -29,13 +29,11 @@ export default class SlRadioGroup extends LitElement {
handleFocusIn() {
// When tabbing into the fieldset, make sure it lands on the checked radio
requestAnimationFrame(() => {
const checkedRadio = [...this.defaultSlot.assignedElements({ flatten: true })].find(
el => el.tagName.toLowerCase() === 'sl-radio' && (el as SlRadio).checked
) as SlRadio;
const checkedRadio = [...(this.defaultSlot.assignedElements({ flatten: true }) as SlRadio[])].find(
el => el.tagName.toLowerCase() === 'sl-radio' && el.checked
);
if (checkedRadio) {
checkedRadio.focus();
}
checkedRadio?.focus();
});
}

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,14 +1,12 @@
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import '../../../dist/shoelace.js';
import type SlRadio from './radio';
import type SlRadioGroup from '../radio-group/radio-group';
import type SlRadioGroup from '~/components/radio-group/radio-group';
describe('<sl-radio>', () => {
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlRadio>(html` <sl-radio disabled></sl-radio> `);
const radio = el.shadowRoot?.querySelector('input');
const radio = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
expect(radio.disabled).to.be.true;
});
@@ -21,7 +19,7 @@ describe('<sl-radio>', () => {
it('should fire sl-change when clicked', async () => {
const el = await fixture<SlRadio>(html` <sl-radio></sl-radio> `);
setTimeout(() => el.shadowRoot?.querySelector('input').click());
setTimeout(() => el.shadowRoot?.querySelector('input')?.click());
const event = await oneEvent(el, 'sl-change');
expect(event.target).to.equal(el);
expect(el.checked).to.be.true;
@@ -29,9 +27,11 @@ describe('<sl-radio>', () => {
it('should fire sl-change when toggled via keyboard - space', async () => {
const el = await fixture<SlRadio>(html` <sl-radio></sl-radio> `);
const input = el.shadowRoot?.querySelector('input');
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
input.focus();
setTimeout(() => sendKeys({ press: ' ' }));
setTimeout(() => {
void sendKeys({ press: ' ' });
});
const event = await oneEvent(el, 'sl-change');
expect(event.target).to.equal(el);
expect(el.checked).to.be.true;
@@ -44,11 +44,13 @@ describe('<sl-radio>', () => {
<sl-radio id="radio-2"></sl-radio>
</sl-radio-group>
`);
const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1');
const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2');
const input1 = radio1.shadowRoot?.querySelector('input');
const radio1 = radioGroup.querySelector<SlRadio>('sl-radio#radio-1')!;
const radio2 = radioGroup.querySelector<SlRadio>('sl-radio#radio-2')!;
const input1 = radio1.shadowRoot!.querySelector<HTMLInputElement>('input')!;
input1.focus();
setTimeout(() => sendKeys({ press: 'ArrowRight' }));
setTimeout(() => {
void sendKeys({ press: 'ArrowRight' });
});
const event = await oneEvent(radio2, 'sl-change');
expect(event.target).to.equal(radio2);
expect(radio2.checked).to.be.true;

View File

@@ -3,10 +3,10 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { FormSubmitController } from '../../internal/form-control';
import styles from './radio.styles';
import { emit } from '~/internal/event';
import { FormSubmitController } from '~/internal/form-control';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -29,8 +29,8 @@ export default class SlRadio extends LitElement {
@query('input[type="radio"]') input: HTMLInputElement;
// @ts-ignore
private formSubmitController = new FormSubmitController(this, {
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlRadio) => (control.checked ? control.value : undefined)
});
@@ -58,17 +58,17 @@ export default class SlRadio extends LitElement {
const radios = this.getAllRadios();
const checkedRadio = radios.find(radio => radio.checked);
radios.map(radio => {
if (radio.input) {
setTimeout(() => {
radios.forEach(radio => {
radio.input.tabIndex = -1;
});
if (typeof checkedRadio !== 'undefined') {
checkedRadio.input.tabIndex = 0;
} else if (radios.length > 0) {
radios[0].input.tabIndex = 0;
}
});
if (checkedRadio) {
checkedRadio.input.tabIndex = 0;
} else if (radios.length) {
radios[0].input.tabIndex = 0;
}
}
/** Simulates a click on the radio. */
@@ -101,7 +101,7 @@ export default class SlRadio extends LitElement {
const radioGroup = this.closest('sl-radio-group');
// Radios must be part of a radio group
if (!radioGroup) {
if (radioGroup === null) {
return [this];
}
@@ -109,7 +109,7 @@ export default class SlRadio extends LitElement {
}
getSiblingRadios() {
return this.getAllRadios().filter(radio => radio !== this) as this[];
return this.getAllRadios().filter(radio => radio !== this);
}
handleBlur() {
@@ -122,7 +122,7 @@ export default class SlRadio extends LitElement {
if (this.checked) {
this.input.tabIndex = 0;
this.getSiblingRadios().map(radio => {
this.getSiblingRadios().forEach(radio => {
radio.input.tabIndex = -1;
radio.checked = false;
});
@@ -134,13 +134,11 @@ export default class SlRadio extends LitElement {
emit(this, 'sl-change');
}
@watch('disabled')
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.input) {
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
@@ -153,10 +151,14 @@ export default class SlRadio extends LitElement {
const radios = this.getAllRadios().filter(radio => !radio.disabled);
const incr = ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
let index = radios.indexOf(this) + incr;
if (index < 0) index = radios.length - 1;
if (index > radios.length - 1) index = 0;
if (index < 0) {
index = radios.length - 1;
}
if (index > radios.length - 1) {
index = 0;
}
this.getAllRadios().map(radio => {
this.getAllRadios().forEach(radio => {
radio.checked = false;
radio.input.tabIndex = -1;
});

View File

@@ -1,7 +1,7 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import formControlStyles from '../../styles/form-control.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
import formControlStyles from '~/styles/form-control.styles';
export default css`
${componentStyles}

View File

@@ -2,15 +2,13 @@ import { LitElement, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { emit } from '../../internal/event';
import { live } from 'lit/directives/live.js';
import { watch } from '../../internal/watch';
import { getLabelledBy, renderFormControl } from '../../internal/form-control';
import { FormSubmitController } from '../../internal/form-control';
import { HasSlotController } from '../../internal/slot';
import styles from './range.styles';
let id = 0;
import { autoIncrement } from '~/internal/autoIncrement';
import { emit } from '~/internal/event';
import { FormSubmitController, getLabelledBy, renderFormControl } from '~/internal/form-control';
import { HasSlotController } from '~/internal/slot';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -38,14 +36,15 @@ export default class SlRange extends LitElement {
static styles = styles;
@query('.range__control') input: HTMLInputElement;
@query('.range__tooltip') output: HTMLOutputElement;
@query('.range__tooltip') output: HTMLOutputElement | null;
// @ts-ignore
private formSubmitController = new FormSubmitController(this);
private hasSlotController = new HasSlotController(this, 'help-text', 'label');
private inputId = `input-${++id}`;
private helpTextId = `input-help-text-${id}`;
private labelId = `input-label-${id}`;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly attrId = autoIncrement();
private readonly inputId = `input-${this.attrId}`;
private readonly helpTextId = `input-help-text-${this.attrId}`;
private readonly labelId = `input-label-${this.attrId}`;
private resizeObserver: ResizeObserver;
@state() private hasFocus = false;
@@ -89,13 +88,21 @@ export default class SlRange extends LitElement {
connectedCallback() {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.syncRange());
this.resizeObserver = new ResizeObserver(() => {
this.syncRange();
});
if (this.value === undefined || this.value === null) this.value = this.min;
if (this.value < this.min) this.value = this.min;
if (this.value > this.max) this.value = this.max;
if (typeof this.value === 'undefined') {
this.value = this.min;
}
if (this.value < this.min) {
this.value = this.min;
}
if (this.value > this.max) {
this.value = this.max;
}
this.updateComplete.then(() => {
void this.updateComplete.then(() => {
this.syncRange();
this.resizeObserver.observe(this.input);
});
@@ -123,7 +130,7 @@ export default class SlRange extends LitElement {
}
handleInput() {
this.value = Number(this.input.value);
this.value = parseFloat(this.input.value);
emit(this, 'sl-change');
this.syncRange();
@@ -137,22 +144,16 @@ export default class SlRange extends LitElement {
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.value = Number(this.value);
if (this.input) {
this.invalid = !this.input.checkValidity();
}
this.invalid = !this.input.checkValidity();
this.syncRange();
}
@watch('disabled')
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.input) {
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
@@ -186,7 +187,7 @@ export default class SlRange extends LitElement {
}
syncTooltip(percent: number) {
if (this.output) {
if (this.output !== null) {
const inputWidth = this.input.offsetWidth;
const tooltipWidth = this.output.offsetWidth;
const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size');
@@ -238,7 +239,7 @@ export default class SlRange extends LitElement {
min=${ifDefined(this.min)}
max=${ifDefined(this.max)}
step=${ifDefined(this.step)}
.value=${live(String(this.value))}
.value=${live(this.value.toString())}
aria-labelledby=${ifDefined(
getLabelledBy({
label: this.label,

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import { focusVisibleSelector } from '../../internal/focus-visible';
import { focusVisibleSelector } from '~/internal/focus-visible';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -3,12 +3,11 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { clamp } from '../../internal/math';
import styles from './rating.styles';
import '../icon/icon';
import '~/components/icon/icon';
import { emit } from '~/internal/event';
import { clamp } from '~/internal/math';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -50,9 +49,7 @@ export default class SlRating extends LitElement {
@property({ type: Boolean, reflect: true }) disabled = false;
/** The name of the icon to display as the symbol. */
// @ts-ignore
@property() getSymbol: (value: number) => string = (value: number) =>
'<sl-icon name="star-fill" library="system"></sl-icon>';
@property() getSymbol: (value: number) => string = () => '<sl-icon name="star-fill" library="system"></sl-icon>';
/** Sets focus on the rating. */
focus(options?: FocusOptions) {
@@ -185,7 +182,7 @@ export default class SlRating extends LitElement {
})}
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-readonly=${this.readonly ? 'true' : 'false'}
aria-value=${this.value}
aria-valuenow=${this.value}
aria-valuemin=${0}
aria-valuemax=${this.max}
tabindex=${this.disabled ? '-1' : '0'}
@@ -206,7 +203,9 @@ export default class SlRating extends LitElement {
return html`
<span
class=${classMap({
// eslint-disable-next-line @typescript-eslint/naming-convention
rating__symbol: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
})}
role="presentation"
@@ -223,7 +222,9 @@ export default class SlRating extends LitElement {
return html`
<span
class=${classMap({
// eslint-disable-next-line @typescript-eslint/naming-convention
rating__symbol: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
})}
style=${styleMap({

View File

@@ -1,6 +1,21 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { LocalizeController } from '../../utilities/localize';
import { LocalizeController } from '~/utilities/localize';
interface UnitConfig {
max: number;
value: number;
unit: Intl.RelativeTimeFormatUnit;
}
const availableUnits: UnitConfig[] = [
{ max: 2760000, value: 60000, unit: 'minute' }, // max 46 minutes
{ max: 72000000, value: 3600000, unit: 'hour' }, // max 20 hours
{ max: 518400000, value: 86400000, unit: 'day' }, // max 6 days
{ max: 2419200000, value: 604800000, unit: 'week' }, // max 28 days
{ max: 28512000000, value: 2592000000, unit: 'month' }, // max 11 months
{ max: Infinity, value: 31536000000, unit: 'year' }
];
/**
* @since 2.0
@@ -8,8 +23,8 @@ import { LocalizeController } from '../../utilities/localize';
*/
@customElement('sl-relative-time')
export default class SlRelativeTime extends LitElement {
private localize = new LocalizeController(this);
private updateTimeout: any;
private readonly localize = new LocalizeController(this);
private updateTimeout: NodeJS.Timeout;
@state() private isoTime = '';
@state() private relativeTime = '';
@@ -49,16 +64,8 @@ export default class SlRelativeTime extends LitElement {
return '';
}
const diff = +then - +now;
const availableUnits = [
{ max: 2760000, value: 60000, unit: 'minute' }, // max 46 minutes
{ max: 72000000, value: 3600000, unit: 'hour' }, // max 20 hours
{ max: 518400000, value: 86400000, unit: 'day' }, // max 6 days
{ max: 2419200000, value: 604800000, unit: 'week' }, // max 28 days
{ max: 28512000000, value: 2592000000, unit: 'month' }, // max 11 months
{ max: Infinity, value: 31536000000, unit: 'year' }
];
const { unit, value } = availableUnits.find(unit => Math.abs(diff) < unit.max) as any;
const diff = then.getTime() - now.getTime();
const { unit, value } = availableUnits.find(singleUnit => Math.abs(diff) < singleUnit.max)!;
this.isoTime = then.toISOString();
this.titleTime = this.localize.date(then, {
@@ -79,13 +86,6 @@ export default class SlRelativeTime extends LitElement {
clearTimeout(this.updateTimeout);
if (this.sync) {
// Calculates the number of milliseconds until the next respective unit changes. This ensures that all components
// update at the same time which is less distracting than updating independently.
const getTimeUntilNextUnit = (unit: 'second' | 'minute' | 'hour' | 'day') => {
const units = { second: 1000, minute: 60000, hour: 3600000, day: 86400000 };
const value = units[unit];
return value - (now.getTime() % value);
};
let nextInterval: number;
// NOTE: this could be optimized to determine when the next update should actually occur, but the size and cost of
@@ -102,13 +102,23 @@ export default class SlRelativeTime extends LitElement {
nextInterval = getTimeUntilNextUnit('day'); // next day
}
this.updateTimeout = setTimeout(() => this.requestUpdate(), nextInterval);
this.updateTimeout = setTimeout(() => {
this.requestUpdate();
}, nextInterval);
}
return html` <time datetime=${this.isoTime} title=${this.titleTime}>${this.relativeTime}</time> `;
}
}
// Calculates the number of milliseconds until the next respective unit changes. This ensures that all components
// update at the same time which is less distracting than updating independently.
function getTimeUntilNextUnit(unit: 'second' | 'minute' | 'hour' | 'day') {
const units = { second: 1000, minute: 60000, hour: 3600000, day: 86400000 };
const value = units[unit];
return value - (Date.now() % value);
}
declare global {
interface HTMLElementTagNameMap {
'sl-relative-time': SlRelativeTime;

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -1,8 +1,8 @@
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import styles from './resize-observer.styles';
import { emit } from '~/internal/event';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -45,17 +45,19 @@ export default class SlResizeObserver extends LitElement {
}
startObserver() {
const slot = this.shadowRoot!.querySelector('slot')!;
const slot = this.shadowRoot!.querySelector('slot');
if (slot) {
if (slot !== null) {
const elements = slot.assignedElements({ flatten: true }) as HTMLElement[];
// Unwatch previous elements
this.observedElements.map(el => this.resizeObserver.unobserve(el));
this.observedElements.forEach(el => {
this.resizeObserver.unobserve(el);
});
this.observedElements = [];
// Watch new elements
elements.map(el => {
elements.forEach(el => {
this.resizeObserver.observe(el);
this.observedElements.push(el);
});

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

View File

@@ -26,7 +26,7 @@ export default class SlResponsiveMedia extends LitElement {
const split = this.aspectRatio.split(':');
const x = parseFloat(split[0]);
const y = parseFloat(split[1]);
const paddingBottom = x && y ? `${(y / x) * 100}%` : '0';
const paddingBottom = !isNaN(x) && !isNaN(y) && x > 0 && y > 0 ? `${(y / x) * 100}%` : '0';
return html`
<div

View File

@@ -1,6 +1,6 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import formControlStyles from '../../styles/form-control.styles';
import componentStyles from '~/styles/component.styles';
import formControlStyles from '~/styles/form-control.styles';
export default css`
${componentStyles}

View File

@@ -1,7 +1,5 @@
import { expect, fixture, html, waitUntil, aTimeout } from '@open-wc/testing';
import sinon from 'sinon';
import '../../../dist/shoelace.js';
import type SlSelect from './select';
describe('<sl-select>', () => {
@@ -23,14 +21,14 @@ describe('<sl-select>', () => {
});
it('should open the menu when any letter key is pressed with sl-select is on focus', async () => {
const el = (await fixture(html`
const el = await fixture(html`
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`)) as SlSelect;
const control = el.shadowRoot.querySelector('.select__control') as HTMLSelectElement;
`);
const control = el.shadowRoot!.querySelector<HTMLSelectElement>('.select__control')!;
control.focus();
const rKeyEvent = new KeyboardEvent('keydown', { key: 'r' });
control.dispatchEvent(rKeyEvent);
@@ -39,14 +37,14 @@ describe('<sl-select>', () => {
});
it('should not open the menu when ctrl + R is pressed with sl-select is on focus', async () => {
const el = (await fixture(html`
const el = await fixture(html`
<sl-select>
<sl-menu-item value="option-1">Option 1</sl-menu-item>
<sl-menu-item value="option-2">Option 2</sl-menu-item>
<sl-menu-item value="option-3">Option 3</sl-menu-item>
</sl-select>
`)) as SlSelect;
const control = el.shadowRoot.querySelector('.select__control') as HTMLSelectElement;
`);
const control = el.shadowRoot!.querySelector<HTMLSelectElement>('.select__control')!;
control.focus();
const rKeyEvent = new KeyboardEvent('keydown', { key: 'r', ctrlKey: true });
control.dispatchEvent(rKeyEvent);

View File

@@ -1,26 +1,23 @@
import { LitElement, TemplateResult, html } from 'lit';
import type { TemplateResult } from 'lit';
import { LitElement, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { emit } from '../../internal/event';
import { watch } from '../../internal/watch';
import { getLabelledBy, renderFormControl } from '../../internal/form-control';
import { getTextContent } from '../../internal/slot';
import { FormSubmitController } from '../../internal/form-control';
import { HasSlotController } from '../../internal/slot';
import type SlDropdown from '../dropdown/dropdown';
import type SlIconButton from '../icon-button/icon-button';
import type SlMenu from '../menu/menu';
import type SlMenuItem from '../menu-item/menu-item';
import styles from './select.styles';
import '../dropdown/dropdown';
import '../icon/icon';
import '../icon-button/icon-button';
import '../menu/menu';
import '../tag/tag';
let id = 0;
import type SlDropdown from '~/components/dropdown/dropdown';
import '~/components/dropdown/dropdown';
import type SlIconButton from '~/components/icon-button/icon-button';
import '~/components/icon-button/icon-button';
import '~/components/icon/icon';
import type SlMenuItem from '~/components/menu-item/menu-item';
import type SlMenu from '~/components/menu/menu';
import type { MenuSelectEventDetail } from '~/components/menu/menu';
import '~/components/tag/tag';
import { autoIncrement } from '~/internal/autoIncrement';
import { emit } from '~/internal/event';
import { FormSubmitController, getLabelledBy, renderFormControl } from '~/internal/form-control';
import { getTextContent, HasSlotController } from '~/internal/slot';
import { watch } from '~/internal/watch';
/**
* @since 2.0
@@ -66,12 +63,14 @@ export default class SlSelect extends LitElement {
@query('.select__hidden-select') input: HTMLInputElement;
@query('.select__menu') menu: SlMenu;
// @ts-ignore
private formSubmitController = new FormSubmitController(this);
private hasSlotController = new HasSlotController(this, 'help-text', 'label');
private inputId = `select-${++id}`;
private helpTextId = `select-help-text-${id}`;
private labelId = `select-label-${id}`;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly attrId = autoIncrement();
private readonly inputId = `select-${this.attrId}`;
private readonly helpTextId = `select-help-text-${this.attrId}`;
private readonly labelId = `select-label-${this.attrId}`;
private readonly menuId = `select-menu-${this.attrId}`;
private resizeObserver: ResizeObserver;
@state() private hasFocus = false;
@@ -107,7 +106,7 @@ export default class SlSelect extends LitElement {
@property({ type: Boolean }) hoist = false;
/** The value of the control. This will be a string or an array depending on `multiple`. */
@property() value: string | Array<string> = '';
@property() value: string | string[] = '';
/** Draws a filled select. */
@property({ type: Boolean, reflect: true }) filled = false;
@@ -116,10 +115,10 @@ export default class SlSelect extends LitElement {
@property({ type: Boolean, reflect: true }) pill = false;
/** The select's label. Alternatively, you can use the label slot. */
@property() label: string;
@property() label = '';
/** The select's help text. Alternatively, you can use the help-text slot. */
@property({ attribute: 'help-text' }) helpText: string;
@property({ attribute: 'help-text' }) helpText = '';
/** The select's required attribute. */
@property({ type: Boolean, reflect: true }) required = false;
@@ -133,9 +132,11 @@ export default class SlSelect extends LitElement {
connectedCallback() {
super.connectedCallback();
this.handleMenuSlotChange = this.handleMenuSlotChange.bind(this);
this.resizeObserver = new ResizeObserver(() => this.resizeMenu());
this.resizeObserver = new ResizeObserver(() => {
this.resizeMenu();
});
this.updateComplete.then(() => {
void this.updateComplete.then(() => {
this.resizeObserver.observe(this);
this.syncItemsFromValue();
});
@@ -162,7 +163,7 @@ export default class SlSelect extends LitElement {
}
getItemLabel(item: SlMenuItem) {
const slot = item.shadowRoot!.querySelector('slot:not([name])') as HTMLSlotElement;
const slot = item.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
return getTextContent(slot);
}
@@ -204,17 +205,15 @@ export default class SlSelect extends LitElement {
this.syncItemsFromValue();
}
@watch('disabled')
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
if (this.disabled && this.isOpen) {
this.dropdown.hide();
void this.dropdown.hide();
}
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.input) {
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
@@ -238,7 +237,7 @@ export default class SlSelect extends LitElement {
// Tabbing out of the control closes it
if (event.key === 'Tab') {
if (this.isOpen) {
this.dropdown.hide();
void this.dropdown.hide();
}
return;
}
@@ -249,17 +248,17 @@ export default class SlSelect extends LitElement {
// Show the menu if it's not already open
if (!this.isOpen) {
this.dropdown.show();
void this.dropdown.show();
}
// Focus on a menu item
if (event.key === 'ArrowDown' && firstItem) {
if (event.key === 'ArrowDown' && typeof firstItem !== 'undefined') {
this.menu.setCurrentItem(firstItem);
firstItem.focus();
return;
}
if (event.key === 'ArrowUp' && lastItem) {
if (event.key === 'ArrowUp' && typeof lastItem !== 'undefined') {
this.menu.setCurrentItem(lastItem);
lastItem.focus();
return;
@@ -275,7 +274,7 @@ export default class SlSelect extends LitElement {
if (!this.isOpen && event.key.length === 1) {
event.stopPropagation();
event.preventDefault();
this.dropdown.show();
void this.dropdown.show();
this.menu.typeToSelect(event.key);
}
}
@@ -284,11 +283,11 @@ export default class SlSelect extends LitElement {
this.focus();
}
handleMenuSelect(event: CustomEvent) {
handleMenuSelect(event: CustomEvent<MenuSelectEventDetail>) {
const item = event.detail.item;
if (this.multiple) {
this.value = this.value?.includes(item.value)
this.value = this.value.includes(item.value)
? (this.value as []).filter(v => v !== item.value)
: [...this.value, item.value];
} else {
@@ -314,7 +313,7 @@ export default class SlSelect extends LitElement {
handleMultipleChange() {
// Cast to array | string based on `this.multiple`
const value = this.getValueAsArray();
this.value = this.multiple ? value : value[0] || '';
this.value = this.multiple ? value : value[0] ?? '';
this.syncItemsFromValue();
}
@@ -324,7 +323,7 @@ export default class SlSelect extends LitElement {
// Check for duplicate values in menu items
const values: string[] = [];
items.map(item => {
items.forEach(item => {
if (values.includes(item.value)) {
console.error(`Duplicate value found in <sl-select> menu item: '${item.value}'`, item);
}
@@ -332,12 +331,14 @@ export default class SlSelect extends LitElement {
values.push(item.value);
});
await Promise.all(items.map(item => item.render)).then(() => this.syncItemsFromValue());
await Promise.all(items.map(item => item.render)).then(() => {
this.syncItemsFromValue();
});
}
handleTagInteraction(event: KeyboardEvent | MouseEvent) {
// Don't toggle the menu when a tag's clear button is activated
const path = event.composedPath() as Array<EventTarget>;
const path = event.composedPath();
const clearButton = path.find((el: SlIconButton) => {
if (el instanceof HTMLElement) {
const element = el as HTMLElement;
@@ -346,7 +347,7 @@ export default class SlSelect extends LitElement {
return false;
});
if (clearButton) {
if (typeof clearButton !== 'undefined') {
event.stopPropagation();
}
}
@@ -360,12 +361,9 @@ export default class SlSelect extends LitElement {
}
resizeMenu() {
const box = this.shadowRoot?.querySelector('.select__control') as HTMLElement;
this.menu.style.width = `${box.clientWidth}px`;
this.menu.style.width = `${this.control.clientWidth}px`;
if (this.dropdown) {
this.dropdown.reposition();
}
this.dropdown.reposition();
}
syncItemsFromValue() {
@@ -377,9 +375,9 @@ export default class SlSelect extends LitElement {
// Sync display label and tags
if (this.multiple) {
const checkedItems = items.filter(item => value.includes(item.value)) as SlMenuItem[];
const checkedItems = items.filter(item => value.includes(item.value));
this.displayLabel = checkedItems[0] ? this.getItemLabel(checkedItems[0]) : '';
this.displayLabel = checkedItems.length > 0 ? this.getItemLabel(checkedItems[0]) : '';
this.displayTags = checkedItems.map((item: SlMenuItem) => {
return html`
<sl-tag
@@ -412,9 +410,9 @@ export default class SlSelect extends LitElement {
`);
}
} else {
const checkedItem = items.filter(item => item.value === value[0])[0];
const checkedItem = items.find(item => item.value === value[0]);
this.displayLabel = checkedItem ? this.getItemLabel(checkedItem) : '';
this.displayLabel = typeof checkedItem !== 'undefined' ? this.getItemLabel(checkedItem) : '';
this.displayTags = [];
}
}
@@ -434,7 +432,7 @@ export default class SlSelect extends LitElement {
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');
const hasSelection = this.multiple ? this.value?.length > 0 : this.value !== '';
const hasSelection = this.multiple ? this.value.length > 0 : this.value !== '';
return renderFormControl(
{
@@ -446,7 +444,9 @@ export default class SlSelect extends LitElement {
helpText: this.helpText,
hasHelpTextSlot,
size: this.size,
onLabelClick: () => this.handleLabelClick()
onLabelClick: () => {
this.handleLabelClick();
}
},
html`
<sl-dropdown
@@ -458,7 +458,7 @@ export default class SlSelect extends LitElement {
class=${classMap({
select: true,
'select--open': this.isOpen,
'select--empty': this.value?.length === 0,
'select--empty': this.value.length === 0,
'select--focused': this.hasFocus,
'select--clearable': this.clearable,
'select--disabled': this.disabled,
@@ -494,6 +494,7 @@ export default class SlSelect extends LitElement {
)}
aria-haspopup="true"
aria-expanded=${this.isOpen ? 'true' : 'false'}
aria-controls=${this.menuId}
tabindex=${this.disabled ? '-1' : '0'}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@@ -504,9 +505,11 @@ export default class SlSelect extends LitElement {
</span>
<div class="select__label">
${this.displayTags.length
${this.displayTags.length > 0
? html` <span part="tags" class="select__tags"> ${this.displayTags} </span> `
: this.displayLabel || this.placeholder}
: this.displayLabel.length > 0
? this.displayLabel
: this.placeholder}
</div>
${this.clearable && hasSelection
@@ -544,7 +547,7 @@ export default class SlSelect extends LitElement {
/>
</div>
<sl-menu part="menu" class="select__menu" @sl-select=${this.handleMenuSelect}>
<sl-menu part="menu" class="select__menu" @sl-select=${this.handleMenuSelect} id=${this.menuId}>
<slot @slotchange=${this.handleMenuSlotChange}></slot>
</sl-menu>
</sl-dropdown>

View File

@@ -1,5 +1,5 @@
import { css } from 'lit';
import componentStyles from '../../styles/component.styles';
import componentStyles from '~/styles/component.styles';
export default css`
${componentStyles}

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