From b260a4dc29d05858da3c5b59028a5087199f749a Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Tue, 7 Feb 2023 17:18:03 -0500 Subject: [PATCH] add focus/blur to color picker --- docs/resources/changelog.md | 2 + .../color-picker/color-picker.test.ts | 95 +++++++++++++++++++ src/components/color-picker/color-picker.ts | 74 ++++++++++++++- 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index a3a3bcab2..8c87102d4 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -8,6 +8,8 @@ New versions of Shoelace are released as-needed and generally occur when a criti ## Next +- Added the `sl-focus` and `sl-blur` events to `` +- Added the `focus()` and `blur()` methods to `` - Fixed a bug in `` where the play and pause buttons were transposed [#1147](https://github.com/shoelace-style/shoelace/issues/1147) - Fixed a bug that prevented `web-types.json` from being generated [#1154](https://github.com/shoelace-style/shoelace/discussions/1154) - Fixed a bug in `` that prevented `sl-change` and `sl-input` from emitting when using the eye dropper [#1157](https://github.com/shoelace-style/shoelace/issues/1157) diff --git a/src/components/color-picker/color-picker.test.ts b/src/components/color-picker/color-picker.test.ts index c74975e22..4ff028450 100644 --- a/src/components/color-picker/color-picker.test.ts +++ b/src/components/color-picker/color-picker.test.ts @@ -324,6 +324,101 @@ describe('', () => { expect(previewColor).to.equal('#ff000050'); }); + it('should emit sl-focus when rendered as a dropdown and focused', async () => { + const el = await fixture(html` +
+ + +
+ `); + const colorPicker = el.querySelector('sl-color-picker')!; + const trigger = colorPicker.shadowRoot!.querySelector('[part~="trigger"]')!; + const button = el.querySelector('button')!; + const focusHandler = sinon.spy(); + const blurHandler = sinon.spy(); + + colorPicker.addEventListener('sl-focus', focusHandler); + colorPicker.addEventListener('sl-blur', blurHandler); + + await clickOnElement(trigger); + await colorPicker.updateComplete; + expect(focusHandler).to.have.been.calledOnce; + + await clickOnElement(button); + await colorPicker.updateComplete; + expect(blurHandler).to.have.been.calledOnce; + }); + + it('should emit sl-focus when rendered inline and focused', async () => { + const el = await fixture(html` +
+ + +
+ `); + const colorPicker = el.querySelector('sl-color-picker')!; + const button = el.querySelector('button')!; + const focusHandler = sinon.spy(); + const blurHandler = sinon.spy(); + + colorPicker.addEventListener('sl-focus', focusHandler); + colorPicker.addEventListener('sl-blur', blurHandler); + + await clickOnElement(colorPicker); + await colorPicker.updateComplete; + expect(focusHandler).to.have.been.calledOnce; + + await clickOnElement(button); + await colorPicker.updateComplete; + expect(blurHandler).to.have.been.calledOnce; + }); + + it('should focus and blur when calling focus() and blur() and rendered as a dropdown', async () => { + const colorPicker = await fixture(html` `); + const focusHandler = sinon.spy(); + const blurHandler = sinon.spy(); + + colorPicker.addEventListener('sl-focus', focusHandler); + colorPicker.addEventListener('sl-blur', blurHandler); + + // Focus + colorPicker.focus(); + await colorPicker.updateComplete; + + expect(document.activeElement).to.equal(colorPicker); + expect(focusHandler).to.have.been.calledOnce; + + // Blur + colorPicker.blur(); + await colorPicker.updateComplete; + + expect(document.activeElement).to.equal(document.body); + expect(blurHandler).to.have.been.calledOnce; + }); + + it('should focus and blur when calling focus() and blur() and rendered inline', async () => { + const colorPicker = await fixture(html` `); + const focusHandler = sinon.spy(); + const blurHandler = sinon.spy(); + + colorPicker.addEventListener('sl-focus', focusHandler); + colorPicker.addEventListener('sl-blur', blurHandler); + + // Focus + colorPicker.focus(); + await colorPicker.updateComplete; + + expect(document.activeElement).to.equal(colorPicker); + expect(focusHandler).to.have.been.calledOnce; + + // Blur + colorPicker.blur(); + await colorPicker.updateComplete; + + expect(document.activeElement).to.equal(document.body); + expect(blurHandler).to.have.been.calledOnce; + }); + describe('when submitting a form', () => { it('should serialize its name and value with FormData', async () => { const form = await fixture(html` diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 2df8028a2..abd14a53c 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -49,7 +49,9 @@ declare const EyeDropper: EyeDropperConstructor; * * @slot label - The color picker's form label. Alternatively, you can use the `label` attribute. * + * @event sl-blur Emitted when the color picker loses focus. * @event sl-change Emitted when the color picker's value changes. + * @event sl-focus Emitted when the color picker receives focus. * @event sl-input Emitted when the color picker receives input. * * @csspart base - The component's base wrapper. @@ -94,10 +96,13 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo private isSafeValue = false; private readonly localize = new LocalizeController(this); + @query('[part~="base"]') base: HTMLElement; @query('[part~="input"]') input: SlInput; - @query('[part~="preview"]') previewButton: HTMLButtonElement; @query('.color-dropdown') dropdown: SlDropdown; + @query('[part~="preview"]') previewButton: HTMLButtonElement; + @query('[part~="trigger"]') trigger: HTMLButtonElement; + @state() private hasFocus = false; @state() private isDraggingGridHandle = false; @state() private isEmpty = false; @state() private inputValue = ''; @@ -169,6 +174,20 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo */ @property({ reflect: true }) form = ''; + connectedCallback() { + super.connectedCallback(); + this.handleFocusIn = this.handleFocusIn.bind(this); + this.handleFocusOut = this.handleFocusOut.bind(this); + this.addEventListener('focusin', this.handleFocusIn); + this.addEventListener('focusout', this.handleFocusOut); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('focusin', this.handleFocusIn); + this.removeEventListener('focusout', this.handleFocusOut); + } + private handleCopy() { this.input.select(); document.execCommand('copy'); @@ -181,6 +200,16 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo }); } + private handleFocusIn() { + this.hasFocus = true; + this.emit('sl-focus'); + } + + private handleFocusOut() { + this.hasFocus = false; + this.emit('sl-blur'); + } + private handleFormatToggle() { const formats = ['hex', 'rgb', 'hsl', 'hsv']; const nextIndex = (formats.indexOf(this.format) + 1) % formats.length; @@ -389,6 +418,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo } private handleInputInput(event: CustomEvent) { + this.formControlController.updateValidity(); + // Prevent the 's sl-input event from bubbling up event.stopPropagation(); } @@ -601,6 +632,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo return color.toHex8String(); } + // Prevents nested components from leaking events + private stopNestedEventPropagation(event: CustomEvent) { + event.stopImmediatePropagation(); + } + @watch('format', { waitUntilFirstUpdate: true }) handleFormatChange() { this.syncValues(); @@ -638,6 +674,32 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo } } + /** Sets focus on the color picker. */ + focus(options?: FocusOptions) { + if (this.inline) { + this.base.focus(options); + } else { + this.trigger.focus(options); + } + } + + /** Removes focus from the color picker. */ + blur() { + const elementToBlur = this.inline ? this.base : this.trigger; + + if (this.hasFocus) { + // We don't know which element in the color picker has focus, so we'll move it to the trigger or base (inline) and + // blur that instead. This results in document.activeElement becoming the . This doesn't cause another focus + // event because we're using focusin and something inside the color picker already has focus. + elementToBlur.focus({ preventScroll: true }); + elementToBlur.blur(); + } + + if (this.dropdown?.open) { + this.dropdown.hide(); + } + } + /** Returns the current value as a string in the specified format. */ getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hsv' | 'hsva' = 'hex') { const currentColor = this.parseColor( @@ -706,7 +768,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo class=${classMap({ 'color-picker': true, 'color-picker--inline': this.inline, - 'color-picker--disabled': this.disabled + 'color-picker--disabled': this.disabled, + 'color-picker--focused': this.hasFocus })} aria-disabled=${this.disabled ? 'true' : 'false'} aria-labelledby="label" @@ -835,6 +898,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo @keydown=${this.handleInputKeyDown} @sl-change=${this.handleInputChange} @sl-input=${this.handleInputInput} + @sl-blur=${this.stopNestedEventPropagation} + @sl-focus=${this.stopNestedEventPropagation} > @@ -851,6 +916,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo caret:format-button__caret " @click=${this.handleFormatToggle} + @sl-blur=${this.stopNestedEventPropagation} + @sl-focus=${this.stopNestedEventPropagation} > ${this.setLetterCase(this.format)} @@ -868,6 +935,8 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo caret:eye-dropper-button__caret " @click=${this.handleEyeDropper} + @sl-blur=${this.stopNestedEventPropagation} + @sl-focus=${this.stopNestedEventPropagation} >