From a0a77f799df0732d9f7182f15866035a3ce5a1eb Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Fri, 1 Mar 2024 17:12:05 -0500 Subject: [PATCH] feat(searchbar): autocapitalize, dir, lang, maxlength, and minlength are inherited to native input (#29098) Issue number: resolves #27606 --------- ## What is the current behavior? Certain attributes are not be inherited to the inner searchbar. Developers need control over these attributes to provide important context to users for things like language and text direction. Additionally, being able to control things like autocapitalize, maxlength, and minlength can help improve the user experience by a) guiding what should be entered into an input and b) removing autocapitalize where it's not appropriate. ## What is the new behavior? - Added autocapitalize, maxlength, and minlength properties - lang and dir are global attributes, so adding them as properties will cause issues. However, developers can still set them as attributes and they will be inherited to the native `input` element. We also watch them so any changes to the attributes are also inherited to the native `input`. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Note: We expanded the scope of this work to also include input and textarea, and this work will be handled separately. However, the original request was only for searchbar so that's why I associated this PR with the linked issue. Dev build: `7.7.3-dev.11709159644.114cd8b1` --- core/api.txt | 3 + core/src/components.d.ts | 24 +++++++ core/src/components/searchbar/searchbar.tsx | 70 ++++++++++++++++++- .../searchbar/test/searchbar.spec.ts | 28 +++++++- packages/angular/src/directives/proxies.ts | 4 +- packages/vue/src/proxies.ts | 3 + 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/core/api.txt b/core/api.txt index acdcb8d0fa..498263defe 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1158,6 +1158,7 @@ ion-row,shadow ion-searchbar,scoped ion-searchbar,prop,animated,boolean,false,false,false +ion-searchbar,prop,autocapitalize,string,undefined,true,false ion-searchbar,prop,autocomplete,"name" | "email" | "tel" | "url" | "on" | "off" | "honorific-prefix" | "given-name" | "additional-name" | "family-name" | "honorific-suffix" | "nickname" | "username" | "new-password" | "current-password" | "one-time-code" | "organization-title" | "organization" | "street-address" | "address-line1" | "address-line2" | "address-line3" | "address-level4" | "address-level3" | "address-level2" | "address-level1" | "country" | "country-name" | "postal-code" | "cc-name" | "cc-given-name" | "cc-additional-name" | "cc-family-name" | "cc-number" | "cc-exp" | "cc-exp-month" | "cc-exp-year" | "cc-csc" | "cc-type" | "transaction-currency" | "transaction-amount" | "language" | "bday" | "bday-day" | "bday-month" | "bday-year" | "sex" | "tel-country-code" | "tel-national" | "tel-area-code" | "tel-local" | "tel-extension" | "impp" | "photo",'off',false,false ion-searchbar,prop,autocorrect,"off" | "on",'off',false,false ion-searchbar,prop,cancelButtonIcon,string,config.get('backButtonIcon', arrowBackSharp) as string,false,false @@ -1168,6 +1169,8 @@ ion-searchbar,prop,debounce,number | undefined,undefined,false,false ion-searchbar,prop,disabled,boolean,false,false,false ion-searchbar,prop,enterkeyhint,"done" | "enter" | "go" | "next" | "previous" | "search" | "send" | undefined,undefined,false,false ion-searchbar,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false +ion-searchbar,prop,maxlength,number | undefined,undefined,false,false +ion-searchbar,prop,minlength,number | undefined,undefined,false,false ion-searchbar,prop,mode,"ios" | "md",undefined,false,false ion-searchbar,prop,name,string,this.inputId,false,false ion-searchbar,prop,placeholder,string,'Search',false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index ab938b5bc1..6102aa8db7 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2552,6 +2552,10 @@ export namespace Components { * If `true`, enable searchbar animation. */ "animated": boolean; + /** + * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`. + */ + "autocapitalize": string; /** * Set the input's autocomplete property. */ @@ -2596,6 +2600,14 @@ export namespace Components { * A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`. */ "inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; + /** + * This attribute specifies the maximum number of characters that the user can enter. + */ + "maxlength"?: number; + /** + * This attribute specifies the minimum number of characters that the user can enter. + */ + "minlength"?: number; /** * The mode determines which platform styles to use. */ @@ -7280,6 +7292,10 @@ declare namespace LocalJSX { * If `true`, enable searchbar animation. */ "animated"?: boolean; + /** + * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`. + */ + "autocapitalize": string; /** * Set the input's autocomplete property. */ @@ -7320,6 +7336,14 @@ declare namespace LocalJSX { * A hint to the browser for which keyboard to display. Possible values: `"none"`, `"text"`, `"tel"`, `"url"`, `"email"`, `"numeric"`, `"decimal"`, and `"search"`. */ "inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; + /** + * This attribute specifies the maximum number of characters that the user can enter. + */ + "maxlength"?: number; + /** + * This attribute specifies the minimum number of characters that the user can enter. + */ + "minlength"?: number; /** * The mode determines which platform styles to use. */ diff --git a/core/src/components/searchbar/searchbar.tsx b/core/src/components/searchbar/searchbar.tsx index 21fed733d2..cf48cf4a04 100644 --- a/core/src/components/searchbar/searchbar.tsx +++ b/core/src/components/searchbar/searchbar.tsx @@ -1,6 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core'; -import { debounceEvent, raf, componentOnReady } from '@utils/helpers'; +import { debounceEvent, raf, componentOnReady, inheritAttributes } from '@utils/helpers'; +import type { Attributes } from '@utils/helpers'; import { isRTL } from '@utils/rtl'; import { createColorClasses } from '@utils/theme'; import { arrowBackSharp, closeCircle, closeSharp, searchOutline, searchSharp } from 'ionicons/icons'; @@ -28,6 +29,7 @@ export class Searchbar implements ComponentInterface { private shouldAlignLeft = true; private originalIonInput?: EventEmitter; private inputId = `ion-searchbar-${searchbarIds++}`; + private inheritedAttributes: Attributes = {}; /** * The value of the input when the textarea is focused. @@ -39,6 +41,31 @@ export class Searchbar implements ComponentInterface { @State() focused = false; @State() noAnimate = true; + /** + * lang and dir are globally enumerated attributes. + * As a result, creating these as properties + * can have unintended side effects. Instead, we + * listen for attribute changes and inherit them + * to the inner `` element. + */ + @Watch('lang') + onLangChanged(newValue: string) { + this.inheritedAttributes = { + ...this.inheritedAttributes, + lang: newValue, + }; + forceUpdate(this); + } + + @Watch('dir') + onDirChanged(newValue: string) { + this.inheritedAttributes = { + ...this.inheritedAttributes, + dir: newValue, + }; + forceUpdate(this); + } + /** * The color to use from your application's color palette. * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`. @@ -51,6 +78,27 @@ export class Searchbar implements ComponentInterface { */ @Prop() animated = false; + /** + * Prior to the addition of this property + * autocapitalize was enabled by default on iOS + * and disabled by default on Android + * for Searchbar. The autocapitalize type on HTMLElement + * requires that it be a string and never undefined. + * However, setting it to a string value would be a breaking change + * in behavior, so we use "!" to tell TypeScript that this property + * is always defined so we can rely on the browser defaults. Browsers + * will automatically set a default value if the developer does not set one. + * + * In the future, this property will default to "off" to align with + * Input and Textarea, and the "!" will not be needed. + */ + + /** + * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. + * Available options: `"off"`, `"none"`, `"on"`, `"sentences"`, `"words"`, `"characters"`. + */ + @Prop() autocapitalize!: string; + /** * Set the input's autocomplete property. */ @@ -112,6 +160,16 @@ export class Searchbar implements ComponentInterface { */ @Prop() enterkeyhint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'; + /** + * This attribute specifies the maximum number of characters that the user can enter. + */ + @Prop() maxlength?: number; + + /** + * This attribute specifies the minimum number of characters that the user can enter. + */ + @Prop() minlength?: number; + /** * If used in a form, set the name of the control, which is submitted with the form data. */ @@ -232,6 +290,12 @@ export class Searchbar implements ComponentInterface { this.emitStyle(); } + componentWillLoad() { + this.inheritedAttributes = { + ...inheritAttributes(this.el, ['lang', 'dir']), + }; + } + componentDidLoad() { this.originalIonInput = this.ionInput; this.positionElements(); @@ -614,12 +678,16 @@ export class Searchbar implements ComponentInterface { onChange={this.onChange} onBlur={this.onBlur} onFocus={this.onFocus} + minLength={this.minlength} + maxLength={this.maxlength} placeholder={this.placeholder} type={this.type} value={this.getValue()} + autoCapitalize={this.autocapitalize} autoComplete={this.autocomplete} autoCorrect={this.autocorrect} spellcheck={this.spellcheck} + {...this.inheritedAttributes} /> {mode === 'md' && cancelButton} diff --git a/core/src/components/searchbar/test/searchbar.spec.ts b/core/src/components/searchbar/test/searchbar.spec.ts index ad3a1f9137..0622a69ace 100644 --- a/core/src/components/searchbar/test/searchbar.spec.ts +++ b/core/src/components/searchbar/test/searchbar.spec.ts @@ -3,13 +3,37 @@ import { newSpecPage } from '@stencil/core/testing'; import { Searchbar } from '../searchbar'; describe('searchbar: rendering', () => { - it('should inherit attributes', async () => { + it('should inherit properties on load', async () => { const page = await newSpecPage({ components: [Searchbar], - html: '', + html: '', }); const nativeEl = page.body.querySelector('ion-searchbar input')!; expect(nativeEl.getAttribute('name')).toBe('search'); + expect(nativeEl.getAttribute('maxlength')).toBe('4'); + expect(nativeEl.getAttribute('minlength')).toBe('2'); + expect(nativeEl.getAttribute('autocapitalize')).toBe('off'); + }); + + it('should inherit watched attributes', async () => { + const page = await newSpecPage({ + components: [Searchbar], + html: '', + }); + + const searchbarEl = page.body.querySelector('ion-searchbar')!; + const nativeEl = searchbarEl.querySelector('input')!; + + expect(nativeEl.getAttribute('lang')).toBe('en-US'); + expect(nativeEl.getAttribute('dir')).toBe('ltr'); + + searchbarEl.setAttribute('lang', 'es-ES'); + searchbarEl.setAttribute('dir', 'rtl'); + + await page.waitForChanges(); + + expect(nativeEl.getAttribute('lang')).toBe('es-ES'); + expect(nativeEl.getAttribute('dir')).toBe('rtl'); }); }); diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index a592c55894..b60fd38f7f 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1788,7 +1788,7 @@ export declare interface IonRow extends Components.IonRow {} @ProxyCmp({ - inputs: ['animated', 'autocomplete', 'autocorrect', 'cancelButtonIcon', 'cancelButtonText', 'clearIcon', 'color', 'debounce', 'disabled', 'enterkeyhint', 'inputmode', 'mode', 'name', 'placeholder', 'searchIcon', 'showCancelButton', 'showClearButton', 'spellcheck', 'type', 'value'], + inputs: ['animated', 'autocapitalize', 'autocomplete', 'autocorrect', 'cancelButtonIcon', 'cancelButtonText', 'clearIcon', 'color', 'debounce', 'disabled', 'enterkeyhint', 'inputmode', 'maxlength', 'minlength', 'mode', 'name', 'placeholder', 'searchIcon', 'showCancelButton', 'showClearButton', 'spellcheck', 'type', 'value'], methods: ['setFocus', 'getInputElement'] }) @Component({ @@ -1796,7 +1796,7 @@ export declare interface IonRow extends Components.IonRow {} changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['animated', 'autocomplete', 'autocorrect', 'cancelButtonIcon', 'cancelButtonText', 'clearIcon', 'color', 'debounce', 'disabled', 'enterkeyhint', 'inputmode', 'mode', 'name', 'placeholder', 'searchIcon', 'showCancelButton', 'showClearButton', 'spellcheck', 'type', 'value'], + inputs: ['animated', 'autocapitalize', 'autocomplete', 'autocorrect', 'cancelButtonIcon', 'cancelButtonText', 'clearIcon', 'color', 'debounce', 'disabled', 'enterkeyhint', 'inputmode', 'maxlength', 'minlength', 'mode', 'name', 'placeholder', 'searchIcon', 'showCancelButton', 'showClearButton', 'spellcheck', 'type', 'value'], }) export class IonSearchbar { protected el: HTMLElement; diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 57380e47bd..5d0272ce08 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -676,6 +676,7 @@ export const IonRow = /*@__PURE__*/ defineContainer('ion-row', defin export const IonSearchbar = /*@__PURE__*/ defineContainer('ion-searchbar', defineIonSearchbar, [ 'color', 'animated', + 'autocapitalize', 'autocomplete', 'autocorrect', 'cancelButtonIcon', @@ -685,6 +686,8 @@ export const IonSearchbar = /*@__PURE__*/ defineContainer