From 5bbf54821bccc956616810baece3f9c23cf58f5d Mon Sep 17 00:00:00 2001 From: Pierre DE SOYRES <pierre.de-soyres@clever-cloud.com> Date: Mon, 25 Mar 2024 17:20:45 +0100 Subject: [PATCH] feat(cc-input-text)!: implement element internals BREAKING CHANGE: an `ElementInternals` polyfill is required to support Safari before version 16.4 --- src/components/cc-input-text/cc-input-text.js | 188 ++++++++++++++---- .../cc-input-text/cc-input-text.stories.js | 25 ++- src/translations/translations.en.js | 3 + src/translations/translations.fr.js | 3 + 4 files changed, 169 insertions(+), 50 deletions(-) diff --git a/src/components/cc-input-text/cc-input-text.js b/src/components/cc-input-text/cc-input-text.js index 35313120a..089a42ca2 100644 --- a/src/components/cc-input-text/cc-input-text.js +++ b/src/components/cc-input-text/cc-input-text.js @@ -1,13 +1,16 @@ -import { css, html, LitElement } from 'lit'; +import { css, html } from 'lit'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref } from 'lit/directives/ref.js'; import { + iconRemixCheckLine as iconCheck, iconRemixClipboardLine as iconClipboard, - iconRemixEyeOffLine as iconEyeClosed, iconRemixEyeLine as iconEyeOpen, - iconRemixCheckLine as iconCheck, + iconRemixEyeOffLine as iconEyeClosed, } from '../../assets/cc-remix.icons.js'; import { dispatchCustomEvent } from '../../lib/events.js'; +import { CcFormControlElement } from '../../lib/form/cc-form-control-element.abstract.js'; +import { combineValidators, EmailValidator, RequiredValidator } from '../../lib/form/validation.js'; import { i18n } from '../../lib/i18n.js'; import { arrayEquals } from '../../lib/utils.js'; import { accessibilityStyles } from '../../styles/accessibility.js'; @@ -16,6 +19,16 @@ import '../cc-icon/cc-icon.js'; const TAG_SEPARATOR = ' '; +/** + * @typedef {import('lit/directives/ref.js').Ref<HTMLInputElement|HTMLTextAreaElement>} HTMLInputOrTextareaElementRef + * @typedef {import('lit/directives/ref.js').Ref<HTMLElement>} HTMLElementRef + * @typedef {import('../../lib/events.types.js').EventWithTarget<HTMLInputElement|HTMLTextAreaElement>} HTMLInputOrTextareaEvent + * @typedef {import('../../lib/events.types.js').GenericEventWithTarget<KeyboardEvent,HTMLInputElement|HTMLTextAreaElement>} HTMLInputOrTextareaKeyboardEvent + * @typedef {import('../../lib/form/validation.types.js').Validator} Validator + * @typedef {import('../../lib/form/validation.types.js').ErrorMessageMap} ErrorMessageMap + * @typedef {import('../../lib/form/form.types.js').FormControlData} FormControlData + */ + /** * An enhanced text input with support for multiline, copy-to-clipboard, show/hide secret and highlighted tags. * @@ -25,7 +38,7 @@ const TAG_SEPARATOR = ' '; * * When you use it with `readonly` \+ `clipboard` \+ NOT `multi`, the width of the input auto adapts to the length of the content. * * The `secret` feature only works for simple line mode (when `multi` is false). * * The `tags` feature enables a space-separated-value input wrapped on several lines where line breaks are not allowed. Don't use it with `multi` or `secret`. - * * When an error slot is used, the input is decorated with a red border and a redish focus ring. You have to be aware that it uses the [`slotchange`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/slotchange_event) event which doesn't fire if the children of a slotted node change. + * * When an `errorMessage` is set, the input is decorated with a red border and a redish focus ring. * * @cssdisplay inline-block / block (with `[multi]`) * @@ -39,34 +52,36 @@ const TAG_SEPARATOR = ' '; * @cssprop {FontSize} --cc-input-label-font-size - The font-size for the input's label (defaults: `inherit`). * @cssprop {FontWeight} --cc-input-label-font-weight - The font-weight for the input's label (defaults: `normal`). * - * @slot error - The error message to be displayed below the `<input>` element or below the help text. Please use a `<p>` tag. * @slot help - The help message to be displayed right below the `<input>` element. Please use a `<p>` tag. */ -export class CcInputText extends LitElement { +export class CcInputText extends CcFormControlElement { static get properties () { return { + ...super.properties, clipboard: { type: Boolean, reflect: true }, disabled: { type: Boolean, reflect: true }, label: { type: String }, hiddenLabel: { type: Boolean, attribute: 'hidden-label' }, inline: { type: Boolean, reflect: true }, multi: { type: Boolean, reflect: true }, - name: { type: String, reflect: true }, placeholder: { type: String }, readonly: { type: Boolean, reflect: true }, required: { type: Boolean }, + resetValue: { type: String, attribute: 'reset-value' }, secret: { type: Boolean, reflect: true }, skeleton: { type: Boolean, reflect: true }, tags: { type: Array }, + type: { type: String, reflect: true }, value: { type: String }, _copyOk: { type: Boolean, state: true }, _showSecret: { type: Boolean, state: true }, _tagsEnabled: { type: Boolean, state: true }, - _hasError: { type: Boolean, state: true }, }; } + static reactiveValidationProperties = ['required', 'type']; + constructor () { super(); @@ -90,9 +105,6 @@ export class CcInputText extends LitElement { /** @type {boolean} Enables multiline support (with a `<textarea>` instead of an `<input>`). */ this.multi = false; - /** @type {string|null} Sets `name` attribute on inner native `<input>/<textarea>` element. */ - this.name = null; - /** @type {string} Sets `placeholder` attribute on inner native `<input>/<textarea>` element. */ this.placeholder = ''; @@ -102,6 +114,9 @@ export class CcInputText extends LitElement { /** @type {boolean} Sets required mention inside the `<label>` element. */ this.required = false; + /** @type {string} Sets the `value` to set when parent `<form>` element is reset. */ + this.resetValue = ''; + /** @type {boolean} Enables show/hide secret feature with an eye icon. */ this.secret = false; @@ -111,21 +126,104 @@ export class CcInputText extends LitElement { /** @type {string[]} Sets list of tags and enables tags mode (if not null). */ this.tags = null; + /** @type {'text'|'email'} The type of the input. Setting this to `email` will add a validity constraint on this input. */ + this.type = 'text'; + /** @type {string} Sets `value` attribute on inner native input element or textarea's inner content. */ this.value = ''; /** @type {boolean} */ this._copyOk = false; + /** @type {HTMLElementRef} */ + this._errorRef = createRef(); + + /** @type {HTMLInputOrTextareaElementRef} */ + this._inputRef = createRef(); + /** @type {boolean} */ this._showSecret = false; /** @type {boolean} */ this._tagsEnabled = false; - this._hasError = false; + /** @type {ErrorMessageMap} */ + this._errorMessages = { + empty: () => { + if (this.type === 'email') { + return i18n('cc-input-text.error.empty.email'); + } + else { + return i18n('cc-input-text.error.empty'); + } + }, + badEmail: () => i18n('cc-input-text.error.bad-email'), + }; + } + + /* region CcFormControlElement implementation */ + + /** + * @return {HTMLElement} + * @protected + */ + _getFormControlElement () { + return this._inputRef.value; + } + + /** + * @return {HTMLElement} + * @protected + */ + _getErrorElement () { + return this._errorRef.value; + } + + /** + * @return {ErrorMessageMap} + * @protected + */ + _getErrorMessages () { + return this._errorMessages; + } + + /** + * @return {Validator} + * @protected + */ + _getValidator () { + return combineValidators([ + this.required ? new RequiredValidator() : null, + this.type === 'email' ? new EmailValidator() : null, + ]); + } + + /** + * @return {FormControlData} + * @protected + */ + _getFormControlData () { + if (this._tagsEnabled) { + const data = new FormData(); + this.tags.forEach((tag) => { + data.append(this.name, tag); + }); + return data; + } + + return this.value; + } + + /** + * @return {Array<string>} + * @protected + */ + _getReactiveValidationProperties () { + return CcInputText.reactiveValidationProperties; } + /* endregion */ + // In general, we try to use LitElement's update() lifecycle callback but in this situation, // overriding get/set makes more sense get tags () { @@ -153,9 +251,12 @@ export class CcInputText extends LitElement { * Triggers focus on the inner `<input>/<textarea>` element. */ focus () { - this.shadowRoot.querySelector('.input').focus(); + this._inputRef.value?.focus(); } + /** + * @param {HTMLInputOrTextareaEvent} e + */ _onInput (e) { // If tags mode is enabled, we want to prevent/remove line breaks // and preserve caret position in case of a line break entry (keypress, DnD, copy/paste...) @@ -170,12 +271,16 @@ export class CcInputText extends LitElement { } } this.value = e.target.value; + dispatchCustomEvent(this, 'input', this.value); if (this._tagsEnabled) { dispatchCustomEvent(this, 'tags', this.tags); } } + /** + * @param {HTMLInputOrTextareaEvent} e + */ _onFocus (e) { if (this.readonly) { e.target.select(); @@ -193,7 +298,11 @@ export class CcInputText extends LitElement { this._showSecret = !this._showSecret; } - // Stop propagation of keydown and keypress events (to prevent conflicts with shortcuts) + /** + * Stop propagation of keydown and keypress events (to prevent conflicts with shortcuts) + * + * @param {HTMLInputOrTextareaKeyboardEvent} e + */ _onKeyEvent (e) { if (e.type === 'keydown' || e.type === 'keypress') { e.stopPropagation(); @@ -201,19 +310,19 @@ export class CcInputText extends LitElement { // Here we prevent keydown on enter key from modifying the value if (this._tagsEnabled && e.type === 'keydown' && e.keyCode === 13) { e.preventDefault(); + this._internals.form.requestSubmit(); dispatchCustomEvent(this, 'requestimplicitsubmit'); } // Request implicit submit with keypress on enter key if (!this.readonly && e.type === 'keypress' && e.keyCode === 13) { if ((!this.multi) || (this.multi && e.ctrlKey)) { + this._internals.form.requestSubmit(); dispatchCustomEvent(this, 'requestimplicitsubmit'); } } } - _onErrorSlotChanged (event) { - this._hasError = event.target.assignedNodes()?.length > 0; - } + /* endregion */ render () { const value = this.value ?? ''; @@ -222,6 +331,7 @@ export class CcInputText extends LitElement { // NOTE: For now, we don't support secret when multi is activated const secret = (this.secret && !this.multi && !this.disabled && !this.skeleton); const isTextarea = (this.multi || this._tagsEnabled); + const hasErrorMessage = this.errorMessage != null && this.errorMessage !== ''; const tags = value .split(TAG_SEPARATOR) @@ -237,12 +347,12 @@ export class CcInputText extends LitElement { ` : ''} </label> ` : ''} - + <div class="meta-input"> <div class="wrapper ${classMap({ skeleton: this.skeleton })}" - @input=${this._onInput} - @keydown=${this._onKeyEvent} - @keypress=${this._onKeyEvent}> + @input=${this._onInput} + @keydown=${this._onKeyEvent} + @keypress=${this._onKeyEvent}> ${isTextarea ? html` ${this._tagsEnabled && !this.skeleton ? html` @@ -250,24 +360,22 @@ export class CcInputText extends LitElement { We use this to display colored background rectangles behind space separated values. This needs to be on the same line and the 2 level parent is important to keep scroll behaviour. --> - <div class="input input-underlayer" style="--rows: ${rows}"><!-- - --><div class="all-tags">${tags}</div><!-- - --></div> + <div class="input input-underlayer" style="--rows: ${rows}"><div class="all-tags">${tags}</div></div> ` : ''} <textarea id="input-id" - class="input ${classMap({ 'input-tags': this._tagsEnabled, error: this._hasError })}" + class="input ${classMap({ 'input-tags': this._tagsEnabled, error: hasErrorMessage })}" style="--rows: ${rows}" rows=${rows} ?disabled=${this.disabled || this.skeleton} ?readonly=${this.readonly} .value=${value} - name=${ifDefined(this.name ?? undefined)} placeholder=${this.placeholder} spellcheck="false" wrap="${ifDefined(this._tagsEnabled ? 'soft' : undefined)}" aria-describedby="help-id error-id" @focus=${this._onFocus} + ${ref(this._inputRef)} ></textarea> ` : ''} @@ -283,15 +391,15 @@ export class CcInputText extends LitElement { <input id="input-id" type=${this.secret && !this._showSecret ? 'password' : 'text'} - class="input ${classMap({ error: this._hasError })}" + class="input ${classMap({ error: hasErrorMessage })}" ?disabled=${this.disabled || this.skeleton} ?readonly=${this.readonly} .value=${value} - name=${ifDefined(this.name ?? undefined)} placeholder=${this.placeholder} spellcheck="false" aria-describedby="help-id error-id" @focus=${this._onFocus} + ${ref(this._inputRef)} > ` : ''} @@ -300,7 +408,7 @@ export class CcInputText extends LitElement { ${secret ? html` <button class="btn" @click=${this._onClickSecret} - title=${this._showSecret ? i18n('cc-input-text.secret.hide') : i18n('cc-input-text.secret.show')} + title=${this._showSecret ? i18n('cc-input-text.secret.hide') : i18n('cc-input-text.secret.show')} > <cc-icon class="btn-img" @@ -323,14 +431,15 @@ export class CcInputText extends LitElement { ` : ''} </div> - + <div class="help-container" id="help-id"> <slot name="help"></slot> </div> - <div class="error-container" id="error-id" > - <slot name="error" @slotchange="${this._onErrorSlotChanged}"></slot> - </div> + ${hasErrorMessage ? html` + <p class="error-container" id="error-id" ${ref(this._errorRef)}> + ${this.errorMessage} + </p>` : ''} `; } @@ -415,11 +524,12 @@ export class CcInputText extends LitElement { color: var(--cc-color-text-weak); font-size: 0.9em; } - - slot[name='error']::slotted(*) { + + .error-container { margin: 0.5em 0 0; color: var(--cc-color-text-danger); } + /* endregion */ .meta-input { @@ -552,7 +662,7 @@ export class CcInputText extends LitElement { border-radius: var(--cc-border-radius-default, 0.25em); box-shadow: 0 0 0 0 rgb(255 255 255 / 0%); } - + .input.error + .ring { border-color: var(--cc-color-border-danger) !important; } @@ -562,8 +672,8 @@ export class CcInputText extends LitElement { outline: var(--cc-focus-outline, #000 solid 2px); outline-offset: var(--cc-focus-outline-offset, 2px); } - - input.error:focus + .ring { + + .input.error:focus + .ring { outline: var(--cc-focus-outline-error, #000 solid 2px); outline-offset: var(--cc-focus-outline-offset, 2px); } @@ -645,7 +755,7 @@ export class CcInputText extends LitElement { .btn-img { --cc-icon-color: var(--cc-input-btn-icons-color, #595959); - + box-sizing: border-box; padding: 15%; } diff --git a/src/components/cc-input-text/cc-input-text.stories.js b/src/components/cc-input-text/cc-input-text.stories.js index cf4a1e5e1..7ef033986 100644 --- a/src/components/cc-input-text/cc-input-text.stories.js +++ b/src/components/cc-input-text/cc-input-text.stories.js @@ -118,7 +118,7 @@ export const errorMessage = makeStory(conf, { items: baseItems.map((p) => ({ ...p, required: true, - innerHTML: '<p slot="error">You must enter a value</p>', + errorMessage: 'You must enter a value', })), }); @@ -126,9 +126,9 @@ export const errorMessageWithHelpMessage = makeStory(conf, { items: baseItems.map((p) => ({ ...p, required: true, + errorMessage: 'You must enter a value', innerHTML: ` <p slot="help">Must be at least 7 characters long</p> - <p slot="error">You must enter a value</p> `, })), }); @@ -153,9 +153,9 @@ export const inlineWithErrorAndHelpMessages = makeStory(conf, { ...p, inline: true, required: true, + errorMessage: 'You must enter a value', innerHTML: ` <p slot="help">Must be at least 7 characters long</p> - <p slot="error">You must enter a value</p> `, })), }); @@ -260,11 +260,12 @@ export const customLabelStyle = makeStory(conf, { })), ...customBaseItems.map((item) => ({ ...item, - innerHTML: `<p slot="error">You must enter a value</p>`, + errorMessage: 'You must enter a value', })), ...customBaseItems.map((item) => ({ ...item, - innerHTML: `<p slot="help">Must be at least 7 characters long</p><p slot="error">You must enter a value</p>`, + errorMessage: 'You must enter a value', + innerHTML: `<p slot="help">Must be at least 7 characters long</p>`, })), ...customBaseItems.map((item) => ({ ...item, @@ -278,12 +279,13 @@ export const customLabelStyle = makeStory(conf, { ...customBaseItems.map((item) => ({ ...item, inline: true, - innerHTML: `<p slot="error">You must enter a value</p>`, + errorMessage: 'You must enter a value', })), ...customBaseItems.map((item) => ({ ...item, inline: true, - innerHTML: `<p slot="help">Must be at least 7 characters long</p><p slot="error">You must enter a value</p>`, + errorMessage: 'You must enter a value', + innerHTML: `<p slot="help">Must be at least 7 characters long</p>`, })), ], }); @@ -295,25 +297,26 @@ export const simulation = makeStory(conf, { simulations: [ storyWait(0, ([component]) => { component.innerHTML = ` - <p slot="help">No error slot, no focus</p> + <p slot="help">No error, no focus</p> `; }), storyWait(2000, ([component]) => { + component.errorMessage = 'This is an error message'; component.innerHTML = ` <p slot="help">With error, no focus</p> - <p slot="error">This is an error message</p> `; }), storyWait(2000, ([component]) => { + component.errorMessage = 'This is an error message'; component.innerHTML = ` <p slot="help">With error, with focus</p> - <p slot="error">This is an error message</p> `; component.focus(); }), storyWait(2000, ([component]) => { + component.errorMessage = null; component.innerHTML = ` - <p slot="help">No error slot, with focus</p> + <p slot="help">No error, with focus</p> `; component.focus(); }), diff --git a/src/translations/translations.en.js b/src/translations/translations.en.js index 3b577e245..07b8ac1fe 100644 --- a/src/translations/translations.en.js +++ b/src/translations/translations.en.js @@ -431,6 +431,9 @@ export const translations = { //#endregion //#region cc-input-text 'cc-input-text.clipboard': `Copy to clipboard`, + 'cc-input-text.error.bad-email': () => sanitize`Invalid email address format.<br>Example: john.doe@example.com.`, + 'cc-input-text.error.empty': `You must enter a value.`, + 'cc-input-text.error.empty.email': `Please enter an email address.`, 'cc-input-text.required': `required`, 'cc-input-text.secret.hide': `Hide secret`, 'cc-input-text.secret.show': `Show secret`, diff --git a/src/translations/translations.fr.js b/src/translations/translations.fr.js index a04856730..3cf27615d 100644 --- a/src/translations/translations.fr.js +++ b/src/translations/translations.fr.js @@ -444,6 +444,9 @@ export const translations = { //#endregion //#region cc-input-text 'cc-input-text.clipboard': `Copier dans le presse-papier`, + 'cc-input-text.error.bad-email': () => sanitize`Format d'adresse e-mail invalide.<br>Exemple: john.doe@example.com.`, + 'cc-input-text.error.empty': `Veuillez saisir une valeur.`, + 'cc-input-text.error.empty.email': `Veuillez saisir une adresse e-mail.`, 'cc-input-text.required': `obligatoire`, 'cc-input-text.secret.hide': `Cacher le secret`, 'cc-input-text.secret.show': `Afficher le secret`,