Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5221801
refactor: move RTE background and border to the container
web-padawan Nov 13, 2025
c3b59fd
proto: implement rich text editor label helper validation
ugur-vaadin Oct 27, 2025
7b57fa4
fix: use aria label for nvda
ugur-vaadin Oct 28, 2025
076f3a2
fix: validate on clear
ugur-vaadin Oct 30, 2025
3d2cd8e
test: add tests for the prototype
ugur-vaadin Oct 30, 2025
673c972
test: update snapshots
ugur-vaadin Oct 30, 2025
957af2d
test: update reference screenshots
ugur-vaadin Oct 30, 2025
780989c
fix: cleanup the styles
ugur-vaadin Oct 30, 2025
1a3d024
refactor: only show submenu indicator if there are child items (#10472)
web-padawan Nov 13, 2025
845b5d5
refactor: split padding-container custom CSS property in base styles …
web-padawan Nov 13, 2025
28ba760
feat: add Aura theme attributes to switch between light and dark them…
sissbruecker Nov 13, 2025
d54f9bb
refactor: use font-weight medium for select value button (#10489)
web-padawan Nov 13, 2025
ee78b82
proto: implement rich text editor label helper validation
ugur-vaadin Oct 27, 2025
9d29d06
fix: use aria label for nvda
ugur-vaadin Oct 28, 2025
30a5e64
fix: validate on clear
ugur-vaadin Oct 30, 2025
dc35c39
test: add tests for the prototype
ugur-vaadin Oct 30, 2025
b9951e8
test: update snapshots
ugur-vaadin Oct 30, 2025
7103dc9
test: update reference screenshots
ugur-vaadin Oct 30, 2025
2e0fa1f
fix: cleanup the styles
ugur-vaadin Oct 30, 2025
e8cf949
Merge branch 'proto-implement-rich-text-editor-label-helper-validatio…
ugur-vaadin Nov 13, 2025
2ed04dc
refactor: fix empty space above editor
ugur-vaadin Nov 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -592,4 +598,4 @@ const states = css`
}
`;

export const richTextEditorStyles = [icons, base, content, toolbar, states];
export const richTextEditorStyles = [icons, field, base, content, toolbar, states];
255 changes: 250 additions & 5 deletions packages/rich-text-editor/src/vaadin-rich-text-editor-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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)',
];
}

/**
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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();
Expand All @@ -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), () => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
};
Loading