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` -
\t
'); }); }); + + describe('required indicator', () => { + beforeEach(async () => { + rte = fixtureSync('