diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 2e94405c2..28df8d1c0 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -9,6 +9,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis ## Next - Added `sl-label-change` event to `` +- Added `blur()`, `click()`, and `focus()` methods as well as `sl-blur` and `sl-focus` events to `` [#730](https://github.com/shoelace-style/shoelace/issues/730) - Fixed a bug where updating a menu item's label wouldn't update the display label in `` [#729](https://github.com/shoelace-style/shoelace/issues/729) - Improved performance of `` by caching menu items instead of traversing for them each time diff --git a/src/components/button/button.test.ts b/src/components/button/button.test.ts index e13732afc..0111d9a9b 100644 --- a/src/components/button/button.test.ts +++ b/src/components/button/button.test.ts @@ -1,4 +1,4 @@ -import { expect, fixture, html } from '@open-wc/testing'; +import { expect, fixture, html, waitUntil } from '@open-wc/testing'; import sinon from 'sinon'; import type SlButton from './button'; @@ -195,4 +195,35 @@ describe('', () => { expect(submitter.formNoValidate).to.be.true; }); }); + + describe('when using methods', () => { + it('should emit sl-focus and sl-blur when the button is focused and blurred', async () => { + const el = await fixture(html` Button `); + const focusHandler = sinon.spy(); + const blurHandler = sinon.spy(); + + el.addEventListener('sl-focus', focusHandler); + el.addEventListener('sl-blur', blurHandler); + + el.focus(); + await waitUntil(() => focusHandler.calledOnce); + + el.blur(); + await waitUntil(() => blurHandler.calledOnce); + + expect(focusHandler).to.have.been.calledOnce; + expect(blurHandler).to.have.been.calledOnce; + }); + + it('should emit a click event when calling click()', async () => { + const el = await fixture(html` `); + const clickHandler = sinon.spy(); + + el.addEventListener('click', clickHandler); + el.click(); + await waitUntil(() => clickHandler.calledOnce); + + expect(clickHandler).to.have.been.calledOnce; + }); + }); }); diff --git a/src/components/button/button.ts b/src/components/button/button.ts index 58ebe3bbc..c9bd1d584 100644 --- a/src/components/button/button.ts +++ b/src/components/button/button.ts @@ -186,14 +186,14 @@ export default class SlButton extends LitElement { 'button--has-suffix': this.hasSlotController.test('suffix') })} ?disabled=${ifDefined(isLink ? undefined : this.disabled)} - type=${this.type} + type=${ifDefined(isLink ? undefined : this.type)} name=${ifDefined(isLink ? undefined : this.name)} value=${ifDefined(isLink ? undefined : this.value)} - href=${ifDefined(this.href)} - target=${ifDefined(this.target)} - download=${ifDefined(this.download)} - rel=${ifDefined(this.target ? 'noreferrer noopener' : undefined)} - role="button" + href=${ifDefined(isLink ? this.href : undefined)} + target=${ifDefined(isLink ? this.target : undefined)} + download=${ifDefined(isLink ? this.download : undefined)} + rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)} + role=${ifDefined(isLink ? undefined : 'button')} aria-disabled=${this.disabled ? 'true' : 'false'} tabindex=${this.disabled ? '-1' : '0'} @blur=${this.handleBlur} diff --git a/src/components/icon-button/icon-button.test.ts b/src/components/icon-button/icon-button.test.ts index 8ee1af16e..6f3a7c48e 100644 --- a/src/components/icon-button/icon-button.test.ts +++ b/src/components/icon-button/icon-button.test.ts @@ -1,4 +1,5 @@ import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import sinon from 'sinon'; import type SlIconButton from './icon-button'; type LinkTarget = '_self' | '_blank' | '_parent' | '_top'; @@ -120,4 +121,35 @@ describe('', () => { expect(el.shadowRoot?.querySelector(`a[aria-disabled="true"]`)).to.exist; }); }); + + describe('when using methods', () => { + it('should emit sl-focus and sl-blur when the button is focused and blurred', async () => { + const el = await fixture(html` `); + const focusHandler = sinon.spy(); + const blurHandler = sinon.spy(); + + el.addEventListener('sl-focus', focusHandler); + el.addEventListener('sl-blur', blurHandler); + + el.focus(); + await waitUntil(() => focusHandler.calledOnce); + + el.blur(); + await waitUntil(() => blurHandler.calledOnce); + + expect(focusHandler).to.have.been.calledOnce; + expect(blurHandler).to.have.been.calledOnce; + }); + + it('should emit a click event when calling click()', async () => { + const el = await fixture(html` `); + const clickHandler = sinon.spy(); + + el.addEventListener('click', clickHandler); + el.click(); + await waitUntil(() => clickHandler.calledOnce); + + expect(clickHandler).to.have.been.calledOnce; + }); + }); }); diff --git a/src/components/icon-button/icon-button.ts b/src/components/icon-button/icon-button.ts index 3faf81223..010ef2c1e 100644 --- a/src/components/icon-button/icon-button.ts +++ b/src/components/icon-button/icon-button.ts @@ -1,8 +1,10 @@ -import { html, LitElement } from 'lit'; -import { customElement, property, query } from 'lit/decorators.js'; +import { LitElement } 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 { html, literal } from 'lit/static-html.js'; import '../../components/icon/icon'; +import { emit } from '../../internal/event'; import styles from './icon-button.styles'; /** @@ -11,12 +13,17 @@ import styles from './icon-button.styles'; * * @dependency sl-icon * + * @event sl-blur - Emitted when the icon button loses focus. + * @event sl-focus - Emitted when the icon button gains focus. + * * @csspart base - The component's internal wrapper. */ @customElement('sl-icon-button') export default class SlIconButton extends LitElement { static styles = styles; + @state() private hasFocus = false; + @query('.icon-button') button: HTMLButtonElement | HTMLLinkElement; /** The name of the icon to draw. */ @@ -46,49 +53,73 @@ export default class SlIconButton extends LitElement { /** Disables the button. */ @property({ type: Boolean, reflect: true }) disabled = false; + /** Simulates a click on the icon button. */ + click() { + this.button.click(); + } + + /** Sets focus on the icon button. */ + focus(options?: FocusOptions) { + this.button.focus(options); + } + + /** Removes focus from the icon button. */ + blur() { + this.button.blur(); + } + + handleBlur() { + this.hasFocus = false; + emit(this, 'sl-blur'); + } + + handleFocus() { + this.hasFocus = true; + emit(this, 'sl-focus'); + } + + handleClick(event: MouseEvent) { + if (this.disabled) { + event.preventDefault(); + event.stopPropagation(); + } + } + render() { const isLink = this.href ? true : false; + const tag = isLink ? literal`a` : literal`button`; - const interior = html` - + /* eslint-disable lit/binding-positions, lit/no-invalid-html */ + return html` + <${tag} + part="base" + class=${classMap({ + 'icon-button': true, + 'icon-button--disabled': !isLink && this.disabled, + 'icon-button--focused': this.hasFocus + })} + ?disabled=${ifDefined(isLink ? undefined : this.disabled)} + type=${ifDefined(isLink ? undefined : 'button')} + href=${ifDefined(isLink ? this.href : undefined)} + target=${ifDefined(isLink ? this.target : undefined)} + download=${ifDefined(isLink ? this.download : undefined)} + rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)} + role=${ifDefined(isLink ? undefined : 'button')} + aria-disabled=${this.disabled ? 'true' : 'false'} + aria-label="${this.label}" + tabindex=${this.disabled ? '-1' : '0'} + @blur=${this.handleBlur} + @focus=${this.handleFocus} + @click=${this.handleClick} + > + + `; - - return isLink - ? html` - - ${interior} - - ` - : html` - - `; } }