diff --git a/packages/rich-text-editor/src/styles/vaadin-rich-text-editor-base-styles.js b/packages/rich-text-editor/src/styles/vaadin-rich-text-editor-base-styles.js index 2359616474..6629febe58 100644 --- a/packages/rich-text-editor/src/styles/vaadin-rich-text-editor-base-styles.js +++ b/packages/rich-text-editor/src/styles/vaadin-rich-text-editor-base-styles.js @@ -9,23 +9,25 @@ * license. */ import { css } from 'lit'; +import { field } from '@vaadin/field-base/src/styles/field-base-styles.js'; import { icons } from './vaadin-rich-text-editor-base-icons.js'; const base = css` :host { - background: var(--vaadin-rich-text-editor-background, var(--vaadin-background-color)); - border: var(--vaadin-input-field-border-width, 1px) solid - var(--vaadin-input-field-border-color, var(--vaadin-border-color)); - border-radius: var(--vaadin-input-field-border-radius, var(--vaadin-radius-m)); box-sizing: border-box; display: flex; flex-direction: column; + gap: var(--vaadin-input-field-label-spacing, var(--vaadin-gap-xs)); } :host([hidden]) { display: none !important; } + :host::before { + display: none; + } + .announcer { clip: rect(0, 0, 0, 0); position: fixed; @@ -40,8 +42,12 @@ const base = css` flex: auto; flex-direction: column; max-height: inherit; - min-height: inherit; - border-radius: inherit; + min-height: 0; + background: var(--vaadin-rich-text-editor-background, var(--vaadin-background-color)); + border: var(--vaadin-input-field-border-width, 1px) solid + var(--vaadin-input-field-border-color, var(--vaadin-border-color)); + border-radius: var(--vaadin-input-field-border-radius, var(--vaadin-radius-m)); + outline-offset: calc(var(--vaadin-focus-ring-width) * -1); contain: paint; } @@ -592,4 +598,4 @@ const states = css` } `; -export const richTextEditorStyles = [icons, base, content, toolbar, states]; +export const richTextEditorStyles = [icons, field, base, content, toolbar, states]; diff --git a/packages/rich-text-editor/src/vaadin-rich-text-editor-mixin.js b/packages/rich-text-editor/src/vaadin-rich-text-editor-mixin.js index 617c414a5d..8f15cc16b3 100644 --- a/packages/rich-text-editor/src/vaadin-rich-text-editor-mixin.js +++ b/packages/rich-text-editor/src/vaadin-rich-text-editor-mixin.js @@ -13,6 +13,7 @@ import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js'; import { timeOut } from '@vaadin/component-base/src/async.js'; import { Debouncer } from '@vaadin/component-base/src/debounce.js'; import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js'; +import { FieldMixin } from '@vaadin/field-base/src/field-mixin.js'; const Quill = window.Quill; @@ -97,8 +98,17 @@ const DEFAULT_I18N = { /** * @polymerMixin */ -export const RichTextEditorMixin = (superClass) => - class RichTextEditorMixinClass extends I18nMixin(DEFAULT_I18N, superClass) { +export const RichTextEditorMixin = (superClass) => { + class RichTextEditorMixinClass extends FieldMixin(I18nMixin(DEFAULT_I18N, superClass)) { + constructor() { + super(); + this.ariaTarget = this; + this._errorController.addEventListener('slot-content-changed', (event) => { + const { hasContent, node } = event.detail; + this.__updateDescriptions(hasContent, node, 'error'); + }); + } + static get properties() { return { /** @@ -243,7 +253,11 @@ export const RichTextEditorMixin = (superClass) => } static get observers() { - return ['_valueChanged(value, _editor)', '_disabledChanged(disabled, readonly, _editor)']; + return [ + '_valueChanged(value, _editor)', + '_disabledChanged(disabled, readonly, _editor)', + '__constraintsChanged(required)', + ]; } /** @@ -291,6 +305,19 @@ export const RichTextEditorMixin = (superClass) => super.disconnectedCallback(); this._editor.emitter.disconnect(); + + if (this.__labelTextObserver) { + this.__labelTextObserver.disconnect(); + this.__labelTextObserver = null; + } + if (this.__helperTextObserver) { + this.__helperTextObserver.disconnect(); + this.__helperTextObserver = null; + } + if (this.__errorTextObserver) { + this.__errorTextObserver.disconnect(); + this.__errorTextObserver = null; + } } /** @private */ @@ -324,6 +351,19 @@ export const RichTextEditorMixin = (superClass) => this._editor.emitter.connect(); } + /** + * @return {boolean} + * @override + */ + checkValidity() { + return !this.required || this.__hasValue(); + } + + /** @private */ + __hasValue() { + return this.value !== '' && this.value !== '[{"insert":"\\n"}]'; + } + /** @protected */ ready() { super.ready(); @@ -346,11 +386,28 @@ export const RichTextEditorMixin = (superClass) => this.__setDirection(this.__dir); - const editorContent = editor.querySelector('.ql-editor'); + const editorContent = this.__getEditorContent(); editorContent.setAttribute('role', 'textbox'); editorContent.setAttribute('aria-multiline', 'true'); + if (this.hasAttribute('has-label')) { + const labelNode = this._labelNode; + this.__updateLabels(true, labelNode); + } + + this.__updateRequired(this.required); + + if (this.hasAttribute('has-helper')) { + const helperNode = this._helperNode; + this.__updateDescriptions(true, helperNode, 'helper'); + } + + if (this.hasAttribute('has-error-message')) { + const errorNode = this._errorNode; + this.__updateDescriptions(true, errorNode, 'error'); + } + this._editor.on('text-change', () => { const timeout = 200; this.__debounceSetValue = Debouncer.debounce(this.__debounceSetValue, timeOut.after(timeout), () => { @@ -919,6 +976,170 @@ export const RichTextEditorMixin = (superClass) => } } + /** + * @private + * @override + */ + __labelChanged(hasLabel, labelNode) { + super.__labelChanged(hasLabel, labelNode); + this.__updateLabels(hasLabel, labelNode); + } + + /** + * @private + * @override + */ + __helperChanged(hasHelper, helperNode) { + super.__helperChanged(hasHelper, helperNode); + this.__updateDescriptions(hasHelper, helperNode, 'helper'); + } + + /** @private */ + __constraintsChanged(...constraints) { + const hasConstraints = this.__hasValidConstraints(constraints); + const isLastConstraintRemoved = this.__previousHasConstraints && !hasConstraints; + if ((this.__hasValue() || this.invalid) && hasConstraints) { + this._requestValidation(); + } else if (isLastConstraintRemoved && !this.manualValidation) { + this._setInvalid(false); + } + this.__previousHasConstraints = hasConstraints; + } + + /** + * @param {boolean} required + * @protected + * @override + */ + _requiredChanged(required) { + super._requiredChanged(required); + this.__updateRequired(required); + } + + /** @private */ + __updateLabels(hasLabel, labelNode) { + if (!this._toolbar || !this.__getEditorContent()) { + return; + } + if (this.__labelTextObserver) { + this.__labelTextObserver.disconnect(); + this.__labelTextObserver = null; + } + if (hasLabel && labelNode) { + this.__updateLabelText(labelNode); + this.__labelTextObserver = new MutationObserver(() => { + this.__updateLabelText(labelNode); + }); + this.__labelTextObserver.observe(labelNode, { + childList: true, + characterData: true, + subtree: true, + }); + } else { + this._toolbar.removeAttribute('aria-label'); + this.__getEditorContent().removeAttribute('aria-label'); + } + } + + /** @private */ + __updateLabelText(labelNode) { + const labelText = labelNode.textContent.trim(); + if (labelText) { + this._toolbar.setAttribute('aria-label', labelText); + this.__getEditorContent().setAttribute('aria-label', labelText); + } else { + this._toolbar.removeAttribute('aria-label'); + this.__getEditorContent().removeAttribute('aria-label'); + } + } + + /** @private */ + __updateDescriptions(hasContent, node, type) { + if (!this._toolbar || !this.__getEditorContent()) { + return; + } + const descId = type === 'helper' ? 'rte-shadow-helper-desc' : 'rte-shadow-error-desc'; + const observerKey = type === 'helper' ? '__helperTextObserver' : '__errorTextObserver'; + if (hasContent && node) { + this.__addDescription(node, descId, observerKey); + } else { + this.__removeDescription(descId, observerKey); + } + } + + /** @private */ + __removeDescription(descId, observerKey) { + const descElement = this.shadowRoot.querySelector(`#${descId}`); + if (descElement) { + descElement.remove(); + } + const editorContent = this.__getEditorContent(); + const currentDescribedBy = editorContent.getAttribute('aria-describedby'); + if (currentDescribedBy) { + const ids = currentDescribedBy.split(' ').filter((id) => id !== descId); + if (ids.length > 0) { + editorContent.setAttribute('aria-describedby', ids.join(' ')); + } else { + editorContent.removeAttribute('aria-describedby'); + } + } + if (this[observerKey]) { + this[observerKey].disconnect(); + this[observerKey] = null; + } + } + + /** @private */ + __addDescription(node, descId, observerKey) { + let descElement = this.shadowRoot.querySelector(`#${descId}`); + if (!descElement) { + descElement = document.createElement('div'); + descElement.id = descId; + descElement.hidden = true; + this.shadowRoot.appendChild(descElement); + } + descElement.textContent = node.textContent.trim(); + if (this[observerKey]) { + this[observerKey].disconnect(); + } + this[observerKey] = new MutationObserver(() => { + const updatedText = node.textContent.trim(); + if (descElement && descElement.textContent !== updatedText) { + descElement.textContent = updatedText; + } + }); + this[observerKey].observe(node, { + childList: true, + characterData: true, + subtree: true, + }); + const editorContent = this.__getEditorContent(); + const currentDescribedBy = editorContent.getAttribute('aria-describedby'); + const ids = currentDescribedBy ? currentDescribedBy.split(' ') : []; + if (!ids.includes(descId)) { + ids.push(descId); + } + editorContent.setAttribute('aria-describedby', ids.join(' ')); + } + + /** @private */ + __updateRequired(required) { + const editorContent = this.__getEditorContent(); + if (!editorContent) { + return; + } + if (required) { + editorContent.setAttribute('aria-required', 'true'); + } else { + editorContent.removeAttribute('aria-required'); + } + } + + /** @private */ + __getEditorContent() { + return this.shadowRoot.querySelector('.ql-editor'); + } + /** @private */ _disabledChanged(disabled, readonly, editor) { if (disabled === undefined || readonly === undefined || editor === undefined) { @@ -942,6 +1163,16 @@ export const RichTextEditorMixin = (superClass) => this.__oldDisabled = disabled; } + /** @private */ + __hasValidConstraints(constraints) { + return constraints.some((c) => this.__isValidConstraint(c)); + } + + /** @private */ + __isValidConstraint(constraint) { + return Boolean(constraint) || constraint === 0; + } + /** @private */ _valueChanged(value, editor) { if (value && this.__pendingHtmlValue) { @@ -954,12 +1185,19 @@ export const RichTextEditorMixin = (superClass) => } if (value == null || value === '[{"insert":"\\n"}]') { + this.__clearingValue = true; this.value = ''; return; } if (value === '') { this._clear(); + if (this.__clearingValue) { + if (this.invalid || this.__previousHasConstraints) { + this._requestValidation(); + } + this.__clearingValue = false; + } return; } @@ -991,5 +1229,12 @@ export const RichTextEditorMixin = (superClass) => // Value changed from outside this.__lastCommittedChange = this.value; } + + if (this.invalid || this.__previousHasConstraints) { + this._requestValidation(); + } } - }; + } + + return RichTextEditorMixinClass; +}; diff --git a/packages/rich-text-editor/src/vaadin-rich-text-editor.js b/packages/rich-text-editor/src/vaadin-rich-text-editor.js index 4b1698086c..f3bac313ca 100644 --- a/packages/rich-text-editor/src/vaadin-rich-text-editor.js +++ b/packages/rich-text-editor/src/vaadin-rich-text-editor.js @@ -125,257 +125,275 @@ class RichTextEditor extends RichTextEditorMixin( /** @protected */ render() { return html` -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ +
+ +
-
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ + +
+ +
-
+ +
+ +
diff --git a/packages/rich-text-editor/test/a11y.test.js b/packages/rich-text-editor/test/a11y.test.js index a112a012f0..d866a5b7c1 100644 --- a/packages/rich-text-editor/test/a11y.test.js +++ b/packages/rich-text-editor/test/a11y.test.js @@ -1,6 +1,6 @@ import { expect } from '@vaadin/chai-plugins'; import { sendKeys } from '@vaadin/test-runner-commands'; -import { fixtureSync, keyboardEventFor, nextRender, nextUpdate } from '@vaadin/testing-helpers'; +import { fixtureSync, keyboardEventFor, nextFrame, nextRender, nextUpdate } from '@vaadin/testing-helpers'; import sinon from 'sinon'; import '../src/vaadin-rich-text-editor.js'; import { getDeepActiveElement } from '@vaadin/a11y-base/src/focus-utils.js'; @@ -14,6 +14,587 @@ describe('accessibility', () => { const flushValueDebouncer = () => rte.__debounceSetValue && rte.__debounceSetValue.flush(); + describe('helper', () => { + let helper; + + beforeEach(async () => { + rte = fixtureSync(''); + await nextRender(); + }); + + it('should not have has-helper attribute by default', () => { + expect(rte.hasAttribute('has-helper')).to.be.false; + }); + + it('should set has-helper attribute when helper text is set', async () => { + rte.helperText = 'Helper text'; + await nextFrame(); + expect(rte.hasAttribute('has-helper')).to.be.true; + }); + + it('should remove has-helper attribute when helper text is cleared', async () => { + rte.helperText = 'Helper text'; + await nextFrame(); + rte.helperText = ''; + await nextFrame(); + expect(rte.hasAttribute('has-helper')).to.be.false; + }); + + it('should set id on the lazily added helper element', async () => { + rte.helperText = 'Helper text'; + await nextFrame(); + helper = rte.querySelector('[slot="helper"]'); + const ID_REGEX = /^helper-vaadin-rich-text-editor-\d+$/u; + expect(helper.getAttribute('id')).to.match(ID_REGEX); + }); + + it('should add helper to aria-describedby when helper text is set', async () => { + rte.helperText = 'Helper text'; + await nextFrame(); + helper = rte.querySelector('[slot="helper"]'); + const aria = rte.getAttribute('aria-describedby'); + expect(aria).to.equal(helper.id); + }); + + it('should remove helper from aria-describedby when helper text is cleared', async () => { + rte.helperText = 'Helper text'; + await nextFrame(); + rte.helperText = ''; + await nextFrame(); + expect(rte.hasAttribute('aria-describedby')).to.be.false; + }); + + describe('shadow DOM', () => { + let toolbar, editorContent; + + beforeEach(async () => { + rte = fixtureSync( + '', + ); + await nextRender(); + toolbar = rte.shadowRoot.querySelector('[part="toolbar"]'); + editorContent = rte.shadowRoot.querySelector('.ql-editor'); + }); + + it('should set aria-describedby on host element', () => { + const describedBy = rte.getAttribute('aria-describedby'); + expect(describedBy).to.be.ok; + expect(describedBy).to.include('helper-vaadin-rich-text-editor-'); + }); + + it('should not set aria-describedby on toolbar', () => { + const describedBy = toolbar.getAttribute('aria-describedby'); + expect(describedBy).to.not.be.ok; + }); + + it('should set aria-describedby on editor content', () => { + const describedBy = editorContent.getAttribute('aria-describedby'); + expect(describedBy).to.be.ok; + expect(describedBy).to.equal('rte-shadow-helper-desc'); + }); + + it('should have helper element with the referenced ID', () => { + const describedBy = rte.getAttribute('aria-describedby'); + expect(describedBy).to.be.ok; + const helperId = describedBy.split(' ').find((id) => id.includes('helper')); + expect(helperId).to.be.ok; + const helperElement = rte.querySelector(`#${helperId}`); + + expect(helperElement).to.be.ok; + expect(helperElement.textContent.trim()).to.equal('Helper text'); + + const shadowHelper = rte.shadowRoot.querySelector('#rte-shadow-helper-desc'); + expect(shadowHelper).to.be.ok; + expect(shadowHelper.textContent.trim()).to.equal('Helper text'); + }); + }); + + describe('slotted', () => { + beforeEach(async () => { + rte = fixtureSync(` + +
Custom helper
+
+ `); + await nextRender(); + helper = rte.querySelector('[slot="helper"]'); + }); + + it('should set id on the slotted helper element', () => { + const ID_REGEX = /^helper-vaadin-rich-text-editor-\d+$/u; + expect(helper.getAttribute('id')).to.match(ID_REGEX); + }); + + it('should set has-helper attribute with slotted helper', () => { + expect(rte.hasAttribute('has-helper')).to.be.true; + }); + + it('should not override custom id on the slotted helper', async () => { + rte = fixtureSync(` + +
Custom helper
+
+ `); + await nextRender(); + helper = rte.querySelector('[slot="helper"]'); + expect(helper.getAttribute('id')).to.equal('custom-helper'); + }); + }); + }); + + describe('error message', () => { + let error; + + describe('when invalid', () => { + beforeEach(async () => { + rte = fixtureSync(''); + rte.invalid = true; + await nextRender(); + }); + + it('should not have has-error-message attribute by default', () => { + expect(rte.hasAttribute('has-error-message')).to.be.false; + }); + + it('should set has-error-message attribute when error message is set', async () => { + rte.errorMessage = 'Error message'; + await nextUpdate(rte); + expect(rte.hasAttribute('has-error-message')).to.be.true; + }); + + it('should remove has-error-message attribute when error message is cleared', async () => { + rte.errorMessage = 'Error message'; + await nextUpdate(rte); + await nextFrame(); + rte.errorMessage = ''; + await nextUpdate(rte); + await nextFrame(); + expect(rte.hasAttribute('has-error-message')).to.be.false; + }); + + it('should set id on the lazily added error element', async () => { + rte.errorMessage = 'Error message'; + await nextUpdate(rte); + await nextFrame(); + error = rte.querySelector('[slot="error-message"]'); + const ID_REGEX = /^error-message-vaadin-.+-\d+$/u; + expect(error.getAttribute('id')).to.match(ID_REGEX); + }); + + it('should add error to aria-describedby when field is invalid', async () => { + rte.errorMessage = 'Error message'; + await nextUpdate(rte); + await nextFrame(); + const aria = rte.getAttribute('aria-describedby'); + expect(aria).to.be.ok; + expect(aria).to.match(/error-message-vaadin-.+-\d+/u); + }); + + it('should remove error from aria-describedby when field becomes valid', async () => { + rte.errorMessage = 'Error message'; + await nextUpdate(rte); + await nextFrame(); + rte.invalid = false; + await nextUpdate(rte); + await nextFrame(); + expect(rte.hasAttribute('aria-describedby')).to.be.false; + }); + + describe('shadow DOM', () => { + let toolbar, editorContent; + + beforeEach(async () => { + rte = fixtureSync( + '', + ); + rte.invalid = true; + await nextRender(); + toolbar = rte.shadowRoot.querySelector('[part="toolbar"]'); + editorContent = rte.shadowRoot.querySelector('.ql-editor'); + }); + + it('should set aria-describedby with error ID on host element', () => { + const describedBy = rte.getAttribute('aria-describedby'); + expect(describedBy).to.be.ok; + expect(describedBy).to.include('error-message-vaadin-rich-text-editor-'); + }); + + it('should not set aria-describedby on toolbar', () => { + const describedBy = toolbar.getAttribute('aria-describedby'); + expect(describedBy).to.not.be.ok; + }); + + it('should set aria-describedby with error on editor content', () => { + const describedBy = editorContent.getAttribute('aria-describedby'); + expect(describedBy).to.be.ok; + expect(describedBy).to.equal('rte-shadow-error-desc'); + }); + + it('should have error element with the referenced ID', () => { + const describedBy = rte.getAttribute('aria-describedby'); + expect(describedBy).to.be.ok; + const errorId = describedBy.split(' ').find((id) => id.includes('error-message')); + expect(errorId).to.be.ok; + const errorElement = rte.querySelector(`#${errorId}`); + + expect(errorElement).to.be.ok; + expect(errorElement.textContent.trim()).to.include('Error text'); + + const shadowError = rte.shadowRoot.querySelector('#rte-shadow-error-desc'); + expect(shadowError).to.be.ok; + expect(shadowError.textContent.trim()).to.equal('Error text'); + }); + }); + }); + + describe('when valid', () => { + beforeEach(async () => { + rte = fixtureSync(''); + await nextRender(); + }); + + it('should not add error to aria-describedby when field is valid', async () => { + rte.errorMessage = 'Error message'; + await nextUpdate(rte); + await nextFrame(); + const aria = rte.getAttribute('aria-describedby'); + expect(aria).to.be.null; + }); + + it('should not set has-error-message when field is valid', async () => { + rte.errorMessage = 'Error message'; + await nextUpdate(rte); + await nextFrame(); + expect(rte.hasAttribute('has-error-message')).to.be.false; + }); + }); + + describe('slotted', () => { + beforeEach(async () => { + rte = fixtureSync(` + +
Custom error
+
+ `); + await nextRender(); + error = rte.querySelector('[slot="error-message"]'); + }); + + it('should set id on the slotted error element', () => { + const ID_REGEX = /^error-message-vaadin-rich-text-editor-\d+$/u; + expect(error.getAttribute('id')).to.match(ID_REGEX); + }); + + it('should set has-error-message attribute with slotted error', () => { + expect(rte.hasAttribute('has-error-message')).to.be.true; + }); + + it('should not override custom id on the slotted error', async () => { + rte = fixtureSync(` + +
Custom error
+
+ `); + await nextRender(); + error = rte.querySelector('[slot="error-message"]'); + expect(error.getAttribute('id')).to.equal('custom-error'); + }); + }); + }); + + describe('aria-describedby', () => { + beforeEach(async () => { + rte = fixtureSync(` + + + `); + await nextRender(); + }); + + it('should only contain helper id when the field is valid', () => { + const aria = rte.getAttribute('aria-describedby'); + expect(aria).to.be.ok; + expect(aria).to.match(/^helper-vaadin-.+-\d+$/u); + expect(aria).to.not.match(/error-message/u); + }); + + it('should add error id when the field becomes invalid', async () => { + rte.invalid = true; + await nextUpdate(rte); + await nextFrame(); + const aria = rte.getAttribute('aria-describedby'); + expect(aria).to.be.ok; + expect(aria).to.match(/helper-vaadin-.+-\d+/u); + expect(aria).to.match(/error-message-vaadin-.+-\d+/u); + }); + + it('should remove error id when the field becomes valid', async () => { + rte.invalid = true; + await nextUpdate(rte); + await nextFrame(); + rte.invalid = false; + await nextUpdate(rte); + await nextFrame(); + const aria = rte.getAttribute('aria-describedby'); + expect(aria).to.be.ok; + expect(aria).to.match(/^helper-vaadin-.+-\d+$/u); + expect(aria).to.not.match(/error-message/u); + }); + + it('should have both helper and error when invalid', async () => { + rte.invalid = true; + await nextUpdate(rte); + await nextFrame(); + const aria = rte.getAttribute('aria-describedby'); + const ids = aria.split(' '); + expect(ids).to.have.lengthOf(2); + expect(aria).to.match(/helper-vaadin-.+-\d+/u); + expect(aria).to.match(/error-message-vaadin-.+-\d+/u); + }); + + it('should have both helper and error elements accessible', async () => { + rte.invalid = true; + await nextUpdate(rte); + await nextFrame(); + const describedBy = rte.getAttribute('aria-describedby'); + expect(describedBy).to.be.ok; + const ids = describedBy.split(' '); + const helperId = ids.find((id) => id.includes('helper')); + const errorId = ids.find((id) => id.includes('error-message')); + + expect(helperId).to.be.ok; + expect(errorId).to.be.ok; + + const helperElement = rte.querySelector(`#${helperId}`); + const errorElement = rte.querySelector(`#${errorId}`); + + expect(helperElement).to.be.ok; + expect(errorElement).to.be.ok; + expect(helperElement.textContent.trim()).to.equal('Helper'); + expect(errorElement.textContent.trim()).to.include('Error'); + }); + }); + + describe('label', () => { + let toolbar, editorContent; + + beforeEach(async () => { + rte = fixtureSync(''); + await nextRender(); + toolbar = rte.shadowRoot.querySelector('[part="toolbar"]'); + editorContent = rte.shadowRoot.querySelector('.ql-editor'); + }); + + it('should not have aria-labelledby attribute by default', () => { + expect(rte.hasAttribute('aria-labelledby')).to.be.false; + expect(toolbar.hasAttribute('aria-labelledby')).to.be.false; + expect(editorContent.hasAttribute('aria-labelledby')).to.be.false; + }); + + it('should set id on the lazily added label element', async () => { + const label = document.createElement('label'); + label.setAttribute('slot', 'label'); + + rte.appendChild(label); + await nextFrame(); + + const ID_REGEX = /^label-vaadin-rich-text-editor-\d+$/u; + expect(label.getAttribute('id')).to.match(ID_REGEX); + }); + + it('should not override custom id on the lazily added label', async () => { + const label = document.createElement('label'); + label.setAttribute('slot', 'label'); + label.id = 'custom-label'; + + rte.appendChild(label); + await nextFrame(); + + expect(label.getAttribute('id')).to.equal('custom-label'); + }); + + it('should set aria-labelledby on host, toolbar and editor when adding a label', async () => { + const label = document.createElement('label'); + label.setAttribute('slot', 'label'); + label.textContent = 'Custom Label'; + + rte.appendChild(label); + await nextFrame(); + await nextFrame(); + + const labelId = label.id; + + expect(rte.getAttribute('aria-labelledby')).to.equal(labelId); + + expect(toolbar.getAttribute('aria-label')).to.equal('Custom Label'); + expect(editorContent.getAttribute('aria-label')).to.equal('Custom Label'); + }); + + it('should remove aria-label from shadow DOM elements when removing a label', async () => { + const label = document.createElement('label'); + label.setAttribute('slot', 'label'); + + rte.appendChild(label); + await nextFrame(); + + rte.removeChild(label); + await nextFrame(); + + expect(rte.hasAttribute('aria-labelledby')).to.be.false; + expect(toolbar.hasAttribute('aria-label')).to.be.false; + expect(editorContent.hasAttribute('aria-label')).to.be.false; + }); + + it('should use label property to set aria-label on shadow DOM elements', async () => { + rte.label = 'Rich Text Editor Label'; + await nextFrame(); + + const ariaLabelledBy = rte.getAttribute('aria-labelledby'); + expect(ariaLabelledBy).to.be.ok; + expect(ariaLabelledBy).to.match(/^label-vaadin-.+-\d+$/u); + expect(toolbar.getAttribute('aria-label')).to.equal('Rich Text Editor Label'); + expect(editorContent.getAttribute('aria-label')).to.equal('Rich Text Editor Label'); + }); + + it('should update aria-label on shadow DOM elements when label property changes', async () => { + rte.label = 'Initial Label'; + await nextFrame(); + const initialLabelId = rte.getAttribute('aria-labelledby'); + + rte.label = 'Updated Label'; + await nextFrame(); + const updatedLabelId = rte.getAttribute('aria-labelledby'); + + expect(initialLabelId).to.equal(updatedLabelId); + expect(updatedLabelId).to.match(/^label-vaadin-.+-\d+$/u); + expect(toolbar.getAttribute('aria-label')).to.equal('Updated Label'); + expect(editorContent.getAttribute('aria-label')).to.equal('Updated Label'); + }); + + it('should remove aria-label when label property is cleared', async () => { + rte.label = 'Some Label'; + await nextFrame(); + + rte.label = ''; + await nextFrame(); + + expect(rte.hasAttribute('aria-labelledby')).to.be.false; + expect(toolbar.hasAttribute('aria-label')).to.be.false; + expect(editorContent.hasAttribute('aria-label')).to.be.false; + }); + + describe('with string label', () => { + beforeEach(async () => { + rte = fixtureSync(''); + await nextRender(); + toolbar = rte.shadowRoot.querySelector('[part="toolbar"]'); + editorContent = rte.shadowRoot.querySelector('.ql-editor'); + }); + + it('should set aria-labelledby on host element', () => { + const labelId = rte.getAttribute('aria-labelledby'); + expect(labelId).to.be.ok; + expect(labelId).to.match(/^label-vaadin-rich-text-editor-\d+$/u); + }); + + it('should set aria-label on toolbar', () => { + const label = toolbar.getAttribute('aria-label'); + expect(label).to.equal('Article Content'); + }); + + it('should set aria-label on editor content', () => { + const label = editorContent.getAttribute('aria-label'); + expect(label).to.equal('Article Content'); + }); + + it('should all have the same label text', () => { + const toolbarLabel = toolbar.getAttribute('aria-label'); + const editorLabel = editorContent.getAttribute('aria-label'); + + expect(toolbarLabel).to.equal('Article Content'); + expect(editorLabel).to.equal('Article Content'); + }); + + it('should have the label element with the referenced ID', () => { + const labelId = rte.getAttribute('aria-labelledby'); + const labelElement = rte.querySelector(`#${labelId}`); + + expect(labelElement).to.be.ok; + expect(labelElement.textContent.trim()).to.equal('Article Content'); + }); + }); + + describe('with slotted label', () => { + beforeEach(async () => { + rte = fixtureSync(` + + + + `); + await nextRender(); + toolbar = rte.shadowRoot.querySelector('[part="toolbar"]'); + editorContent = rte.shadowRoot.querySelector('.ql-editor'); + }); + + it('should set aria-labelledby on host element', () => { + const labelId = rte.getAttribute('aria-labelledby'); + expect(labelId).to.be.ok; + }); + + it('should set aria-label on toolbar', () => { + const label = toolbar.getAttribute('aria-label'); + expect(label).to.equal('Custom Label'); + }); + + it('should set aria-label on editor content', () => { + const label = editorContent.getAttribute('aria-label'); + expect(label).to.equal('Custom Label'); + }); + + it('should reference the slotted label element', () => { + const labelId = rte.getAttribute('aria-labelledby'); + const slottedLabel = rte.querySelector('label[slot="label"]'); + + expect(slottedLabel).to.be.ok; + expect(slottedLabel.id).to.equal(labelId); + expect(slottedLabel.textContent).to.equal('Custom Label'); + }); + }); + + describe('updates', () => { + beforeEach(async () => { + rte = fixtureSync(''); + await nextRender(); + toolbar = rte.shadowRoot.querySelector('[part="toolbar"]'); + editorContent = rte.shadowRoot.querySelector('.ql-editor'); + }); + + it('should update aria-label on shadow DOM elements when label changes', async () => { + const initialHostId = rte.getAttribute('aria-labelledby'); + const initialToolbarLabel = toolbar.getAttribute('aria-label'); + const initialEditorLabel = editorContent.getAttribute('aria-label'); + + expect(initialToolbarLabel).to.equal('Initial'); + expect(initialEditorLabel).to.equal('Initial'); + + rte.label = 'Updated Label'; + await nextFrame(); + + const updatedHostId = rte.getAttribute('aria-labelledby'); + const updatedToolbarLabel = toolbar.getAttribute('aria-label'); + const updatedEditorLabel = editorContent.getAttribute('aria-label'); + + expect(updatedHostId).to.equal(initialHostId); + + expect(updatedToolbarLabel).to.equal('Updated Label'); + expect(updatedEditorLabel).to.equal('Updated Label'); + + const labelElement = rte.querySelector(`#${updatedHostId}`); + expect(labelElement.textContent.trim()).to.equal('Updated Label'); + }); + }); + }); + describe('screen readers', () => { beforeEach(async () => { rte = fixtureSync(''); @@ -402,4 +983,71 @@ describe('accessibility', () => { expect(rte.htmlValue).to.equal('

\t

'); }); }); + + describe('required indicator', () => { + beforeEach(async () => { + rte = fixtureSync(''); + await nextRender(); + }); + + it('should have required attribute on host', () => { + expect(rte.hasAttribute('required')).to.be.true; + }); + + it('should have required indicator element', () => { + const indicator = rte.shadowRoot.querySelector('[part="required-indicator"]'); + expect(indicator).to.exist; + }); + + it('should have required indicator visible', () => { + const indicator = rte.shadowRoot.querySelector('[part="required-indicator"]'); + expect(indicator).to.be.ok; + + const display = getComputedStyle(indicator).display; + expect(display).to.not.equal('none'); + }); + + it('should have aria-hidden on required indicator', () => { + const indicator = rte.shadowRoot.querySelector('[part="required-indicator"]'); + expect(indicator.getAttribute('aria-hidden')).to.equal('true'); + }); + }); + + describe('toolbar role', () => { + let toolbar; + + beforeEach(async () => { + rte = fixtureSync(''); + await nextRender(); + toolbar = rte.shadowRoot.querySelector('[part="toolbar"]'); + }); + + it('should have role="toolbar" on toolbar element', () => { + expect(toolbar.getAttribute('role')).to.equal('toolbar'); + }); + + it('should have aria-label on toolbar', () => { + const label = toolbar.getAttribute('aria-label'); + expect(label).to.equal('Editor'); + }); + }); + + describe('editor content attributes', () => { + let editorContent; + + beforeEach(async () => { + rte = fixtureSync(''); + await nextRender(); + editorContent = rte.shadowRoot.querySelector('.ql-editor'); + }); + + it('should have contenteditable on editor', () => { + expect(editorContent.getAttribute('contenteditable')).to.equal('true'); + }); + + it('should have aria-label on editor', () => { + const label = editorContent.getAttribute('aria-label'); + expect(label).to.equal('Editor'); + }); + }); }); diff --git a/packages/rich-text-editor/test/auto-grow.test.js b/packages/rich-text-editor/test/auto-grow.test.js index 104bada99c..a759243854 100644 --- a/packages/rich-text-editor/test/auto-grow.test.js +++ b/packages/rich-text-editor/test/auto-grow.test.js @@ -42,7 +42,11 @@ describe('rich text editor', () => { ['height', 'min-height', 'max-height'].forEach((definedValue, key) => { describe(`defined ${definedValue}`, () => { beforeEach(async () => { - rte = fixtureSync(``); + rte = fixtureSync(` + + `); await nextRender(); editorContainer = rte.shadowRoot.querySelector('.vaadin-rich-text-editor-container'); editorContentContainer = rte.shadowRoot.querySelector('.ql-container'); @@ -57,7 +61,7 @@ describe('rich text editor', () => { }); it('internal flex wrapper should be the same size as rte itself', () => { - expect(rte.clientHeight).to.be.equal(editorContainer.clientHeight); + expect(rte.clientHeight).to.be.approximately(editorContainer.clientHeight, 2); }); it("content container's and content's height should equal flex wrapper's height without toolbar's height", () => { diff --git a/packages/rich-text-editor/test/dom/__snapshots__/rich-text-editor.test.snap.js b/packages/rich-text-editor/test/dom/__snapshots__/rich-text-editor.test.snap.js index 7d33b47e37..d2d125b058 100644 --- a/packages/rich-text-editor/test/dom/__snapshots__/rich-text-editor.test.snap.js +++ b/packages/rich-text-editor/test/dom/__snapshots__/rich-text-editor.test.snap.js @@ -16,19 +16,19 @@ snapshots["vaadin-rich-text-editor host"] = > @@ -416,12 +416,23 @@ snapshots["vaadin-rich-text-editor host"] = > + +