From d0124dbc1c9fa86ac111e01df33ad4313c1ce5a9 Mon Sep 17 00:00:00 2001 From: Felipe Fialho Date: Thu, 25 May 2023 15:26:44 -0300 Subject: [PATCH] feat(core): add textarea component (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: MaurĂ­cio Mutte --- packages/core/src/components.d.ts | 120 ++++++++ .../textarea/stories/textarea.args.ts | 275 ++++++++++++++++++ .../stories/textarea.core.stories.tsx | 129 ++++++++ .../stories/textarea.react.stories.tsx | 109 +++++++ .../src/components/textarea/textarea.scss | 120 ++++++++ .../src/components/textarea/textarea.spec.ts | 209 +++++++++++++ .../core/src/components/textarea/textarea.tsx | 196 +++++++++++++ sonar-project.properties | 2 + 8 files changed, 1160 insertions(+) create mode 100644 packages/core/src/components/textarea/stories/textarea.args.ts create mode 100644 packages/core/src/components/textarea/stories/textarea.core.stories.tsx create mode 100644 packages/core/src/components/textarea/stories/textarea.react.stories.tsx create mode 100644 packages/core/src/components/textarea/textarea.scss create mode 100644 packages/core/src/components/textarea/textarea.spec.ts create mode 100644 packages/core/src/components/textarea/textarea.tsx diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 24acb134e..242dda029 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -123,6 +123,59 @@ export namespace Components { "readonly"?: boolean; "value"?: IonTypes.IonSelect['value']; } + interface AtomTextarea { + "autoGrow": boolean; + "autocomplete"?: 'on' | 'off'; + "autofocus": boolean; + "clearOnEdit": boolean; + "color"?: 'primary' | 'secondary' | 'danger'; + "cols"?: number; + "counter": boolean; + "counterFormatter"?: ( + inputLength: number, + maxLength: number + ) => string | undefined; + "debounce": number; + "disabled": boolean; + "enterKeyHint": | 'enter' + | 'done' + | 'go' + | 'next' + | 'previous' + | 'search' + | 'send'; + "errorText"?: string; + "fill": 'solid' | 'outline'; + "getInputElement": () => Promise; + "hasError": boolean; + "helperText"?: string; + "icon"?: string; + "inputmode"?: | 'none' + | 'text' + | 'tel' + | 'url' + | 'email' + | 'numeric' + | 'decimal' + | 'search'; + "label"?: string; + "labelPlacement"?: 'stacked' | 'floating'; + "maxlength"?: number; + "minlength"?: number; + "mode": Mode; + "name"?: string; + "pattern"?: string; + "placeholder"?: string; + "readonly": boolean; + "required": boolean; + "rows": number; + "setFocus": () => Promise; + "setInputEl": (textareaEl: HTMLIonTextareaElement) => Promise; + "shape": 'round' | undefined; + "step"?: string; + "value"?: IonTypes.IonTextarea['value']; + "wrap"?: 'hard' | 'soft' | 'off'; + } } export interface AtomButtonCustomEvent extends CustomEvent { detail: T; @@ -136,6 +189,10 @@ export interface AtomSelectCustomEvent extends CustomEvent { detail: T; target: HTMLAtomSelectElement; } +export interface AtomTextareaCustomEvent extends CustomEvent { + detail: T; + target: HTMLAtomTextareaElement; +} declare global { interface HTMLAtomButtonElement extends Components.AtomButton, HTMLStencilElement { } @@ -179,6 +236,12 @@ declare global { prototype: HTMLAtomSelectElement; new (): HTMLAtomSelectElement; }; + interface HTMLAtomTextareaElement extends Components.AtomTextarea, HTMLStencilElement { + } + var HTMLAtomTextareaElement: { + prototype: HTMLAtomTextareaElement; + new (): HTMLAtomTextareaElement; + }; interface HTMLElementTagNameMap { "atom-button": HTMLAtomButtonElement; "atom-col": HTMLAtomColElement; @@ -187,6 +250,7 @@ declare global { "atom-input": HTMLAtomInputElement; "atom-row": HTMLAtomRowElement; "atom-select": HTMLAtomSelectElement; + "atom-textarea": HTMLAtomTextareaElement; } } declare namespace LocalJSX { @@ -310,6 +374,60 @@ declare namespace LocalJSX { "readonly"?: boolean; "value"?: IonTypes.IonSelect['value']; } + interface AtomTextarea { + "autoGrow"?: boolean; + "autocomplete"?: 'on' | 'off'; + "autofocus"?: boolean; + "clearOnEdit"?: boolean; + "color"?: 'primary' | 'secondary' | 'danger'; + "cols"?: number; + "counter"?: boolean; + "counterFormatter"?: ( + inputLength: number, + maxLength: number + ) => string | undefined; + "debounce"?: number; + "disabled"?: boolean; + "enterKeyHint"?: | 'enter' + | 'done' + | 'go' + | 'next' + | 'previous' + | 'search' + | 'send'; + "errorText"?: string; + "fill"?: 'solid' | 'outline'; + "hasError"?: boolean; + "helperText"?: string; + "icon"?: string; + "inputmode"?: | 'none' + | 'text' + | 'tel' + | 'url' + | 'email' + | 'numeric' + | 'decimal' + | 'search'; + "label"?: string; + "labelPlacement"?: 'stacked' | 'floating'; + "maxlength"?: number; + "minlength"?: number; + "mode"?: Mode; + "name"?: string; + "onAtomBlur"?: (event: AtomTextareaCustomEvent) => void; + "onAtomChange"?: (event: AtomTextareaCustomEvent) => void; + "onAtomFocus"?: (event: AtomTextareaCustomEvent) => void; + "onAtomInput"?: (event: AtomTextareaCustomEvent) => void; + "pattern"?: string; + "placeholder"?: string; + "readonly"?: boolean; + "required"?: boolean; + "rows"?: number; + "shape"?: 'round' | undefined; + "step"?: string; + "value"?: IonTypes.IonTextarea['value']; + "wrap"?: 'hard' | 'soft' | 'off'; + } interface IntrinsicElements { "atom-button": AtomButton; "atom-col": AtomCol; @@ -318,6 +436,7 @@ declare namespace LocalJSX { "atom-input": AtomInput; "atom-row": AtomRow; "atom-select": AtomSelect; + "atom-textarea": AtomTextarea; } } export { LocalJSX as JSX }; @@ -331,6 +450,7 @@ declare module "@stencil/core" { "atom-input": LocalJSX.AtomInput & JSXBase.HTMLAttributes; "atom-row": LocalJSX.AtomRow & JSXBase.HTMLAttributes; "atom-select": LocalJSX.AtomSelect & JSXBase.HTMLAttributes; + "atom-textarea": LocalJSX.AtomTextarea & JSXBase.HTMLAttributes; } } } diff --git a/packages/core/src/components/textarea/stories/textarea.args.ts b/packages/core/src/components/textarea/stories/textarea.args.ts new file mode 100644 index 000000000..3da50d3f4 --- /dev/null +++ b/packages/core/src/components/textarea/stories/textarea.args.ts @@ -0,0 +1,275 @@ +import { withActions } from '@storybook/addon-actions/decorator' + +import { Category } from '@atomium/storybook-utils/enums/table' + +export const TextareaStoryArgs = { + parameters: { + docs: { + description: { + component: + 'Wrapper of Ionic Textarea component. Read the [Ionic documentation](https://ionicframework.com/docs/api/textarea) for more information about the available properties and possibilities.', + }, + }, + actions: { + handles: ['atomChange', 'atomFocus', 'atomBlur', 'atomInput'], + }, + }, + decorators: [withActions], + argTypes: { + labelPlacement: { + control: 'select', + options: ['floating', 'stacked'], + defaultValue: { summary: 'floating' }, + description: 'To control how the label is placed relative to the control', + table: { + category: Category.PROPERTIES, + }, + }, + color: { + control: 'select', + options: ['primary', 'secondary', 'danger'], + defaultValue: { summary: 'secondary' }, + description: "The color to use from your application's color palette.", + table: { + category: Category.PROPERTIES, + }, + }, + fill: { + control: 'select', + options: ['outline', 'solid'], + defaultValue: { summary: 'solid' }, + description: 'The fill type of the input', + table: { + category: Category.PROPERTIES, + }, + }, + shape: { + control: 'select', + options: ['round', 'undefined'], + defaultValue: { summary: 'round' }, + description: + 'The shape of the input, if "round" it will be rounded, if "undefined" it will be square', + table: { + category: Category.PROPERTIES, + }, + }, + rows: { + control: 'number', + defaultValue: { summary: 4 }, + description: 'The number of rows to display by default.', + table: { + category: Category.PROPERTIES, + }, + }, + wrap: { + control: 'select', + options: ['hard', 'soft', 'off'], + description: 'How the text in the textarea is wrapped.', + table: { + category: Category.PROPERTIES, + }, + }, + mode: { + control: 'select', + options: ['md', 'ios'], + defaultValue: { summary: 'md' }, + description: 'The mode determines which platform styles to use.', + table: { + category: Category.PROPERTIES, + }, + }, + autoGrow: { + control: 'boolean', + defaultValue: { summary: 'false' }, + description: + 'If `true`, the element height will increase based on the value.', + table: { + category: Category.PROPERTIES, + }, + }, + disabled: { + control: 'boolean', + defaultValue: { summary: 'false' }, + description: 'If `true`, the user cannot interact with the input.', + table: { + category: Category.PROPERTIES, + }, + }, + readonly: { + control: 'boolean', + defaultValue: { summary: 'false' }, + description: 'If `true`, the user cannot modify the value.', + table: { + category: Category.PROPERTIES, + }, + }, + icon: { + control: 'text', + description: 'The icon of the input', + table: { + category: Category.PROPERTIES, + }, + }, + value: { + control: 'text', + description: 'The value of native input', + table: { + category: Category.PROPERTIES, + }, + }, + helperText: { + control: 'text', + description: 'The helper text of the input', + table: { + category: Category.PROPERTIES, + }, + }, + label: { + description: 'The label of the input', + table: { + category: Category.PROPERTIES, + }, + }, + placeholder: { + description: 'The placeholder of the input', + table: { + category: Category.PROPERTIES, + }, + }, + errorText: { + description: 'The error text of the input', + table: { + category: Category.PROPERTIES, + }, + }, + hasError: { + description: 'If `true`, the input will be in an error state.', + table: { + category: Category.PROPERTIES, + }, + }, + cols: { + description: 'The number of cols to display by default.', + table: { + category: Category.PROPERTIES, + }, + }, + maxLength: { + description: 'The maximum value length for an input.', + table: { + category: Category.PROPERTIES, + }, + }, + minLength: { + description: 'The minimum value length for an input.', + table: { + category: Category.PROPERTIES, + }, + }, + counter: { + description: + 'If `true`, a counter will be shown counting the number of characters.', + table: { + category: Category.PROPERTIES, + }, + }, + counterFormatter: { + description: + 'Function that accepts a current value of the input, and returns the string to be displayed in the input counter. Note that the returned string must contain the current value within it somewhere, otherwise the counter will not update as the user types. `(inputLength: number, maxLength: number) => string | undefined`', + table: { + category: Category.PROPERTIES, + }, + }, + clearOnEdit: { + description: + 'If `true`, the value will be cleared after focus upon edit. Defaults to true when type is "password", false for all other types.', + table: { + category: Category.PROPERTIES, + }, + }, + enterKeyHint: { + defaultValue: { summary: 'enter' }, + description: + 'A hint to the browser for which enter key to display. Possible values: "enter", "done", "go", "next", "previous", "search", and "send".', + table: { + category: Category.PROPERTIES, + }, + }, + required: { + description: 'If `true`, the input is required.', + table: { + category: Category.PROPERTIES, + }, + }, + inputmode: { + description: + 'A hint to the browser for which keyboard to display. Accepts the following values: - `none` - `text` - `tel` - `url` - `email` - `numeric` - `decimal` - `search`', + table: { + category: Category.PROPERTIES, + }, + }, + debounce: { + description: + 'The amount of time, in milliseconds, to wait to trigger the `atomChange` event after each keystroke.', + table: { + category: Category.PROPERTIES, + }, + }, + atomChange: { + action: 'atomChange', + description: + 'Depending on the way the users interacts with the element, the `atomChange` event fires at a different moment: - When the user commits the change explicitly (e.g. by selecting a date from a date picker for ``, pressing the "Enter" key, etc.). - When the element loses focus after its value has changed: for elements where the users interaction is typing.', + table: { + category: Category.EVENTS, + }, + }, + atomInput: { + action: 'atomInput', + description: + 'Event fires when the value of an `` element has been changed. For elements that accept text input (`type=text`, `type=tel`, etc.), the interface is InputEvent; for others, the interface is Event. If the input is cleared on edit, the type is null.', + table: { + category: Category.EVENTS, + }, + }, + atomFocus: { + action: 'atomFocus', + description: 'Emitted when the input has focus.', + table: { + category: Category.EVENTS, + }, + }, + atomBlur: { + action: 'atomBlur', + description: 'Emitted when the input has blur.', + table: { + category: Category.EVENTS, + }, + }, + setFocus: { + description: + '`setFocus()` sets focus on the specified `atom-input`. Use this method instead of the global `input.focus()`.', + table: { + category: Category.METHODS, + }, + }, + getInputElement: { + description: + '`getInputElement()` returns the native `` element used under the hood. A common use-case is to access the native input when using a `HTMLInputElement` object is necessary.', + table: { + category: Category.METHODS, + }, + }, + }, +} + +export const TextareaComponentArgs = { + color: 'secondary', + labelPlacement: 'floating', + fill: 'solid', + shape: 'round', + rows: 4, + mode: 'md', + disabled: false, + readonly: false, + autoGrow: false, +} diff --git a/packages/core/src/components/textarea/stories/textarea.core.stories.tsx b/packages/core/src/components/textarea/stories/textarea.core.stories.tsx new file mode 100644 index 000000000..8ed19b4f3 --- /dev/null +++ b/packages/core/src/components/textarea/stories/textarea.core.stories.tsx @@ -0,0 +1,129 @@ +import { Meta, StoryObj } from '@storybook/web-components' + +import { html } from 'lit' + +import { TextareaComponentArgs, TextareaStoryArgs } from './textarea.args' + +export default { + title: 'Components/Textarea', + ...TextareaStoryArgs, +} as Meta + +const createTextarea = (args) => { + return html` + + ` +} + +export const Default: StoryObj = { + render: (args) => createTextarea(args), + args: { + ...TextareaComponentArgs, + }, +} + +export const Disabled: StoryObj = { + render: (args) => createTextarea(args), + args: { + ...TextareaComponentArgs, + disabled: true, + }, +} + +export const TextareaIcon: StoryObj = { + render: (args) => createTextarea(args), + args: { + ...TextareaComponentArgs, + icon: 'people', + }, +} + +export const HelperText: StoryObj = { + render: (args) => createTextarea(args), + args: { + ...TextareaComponentArgs, + helperText: 'This is a helper text example', + }, +} + +export const ErrorState: StoryObj = { + render: () => html` + + + + `, + parameters: { + docs: { + description: { + story: + 'To check this behavior working you need to look the [canvas of component](/story/components-input--error-text)', + }, + }, + }, +} + +export const WithCounter: StoryObj = { + render: () => html` + + + + `, +} diff --git a/packages/core/src/components/textarea/stories/textarea.react.stories.tsx b/packages/core/src/components/textarea/stories/textarea.react.stories.tsx new file mode 100644 index 000000000..007a3e145 --- /dev/null +++ b/packages/core/src/components/textarea/stories/textarea.react.stories.tsx @@ -0,0 +1,109 @@ +import { Meta, StoryObj } from '@storybook/react' +import React from 'react' + +import { AtomTextarea } from '@juntossomosmais/atomium/react' + +import { TextareaComponentArgs, TextareaStoryArgs } from './textarea.args' + +export default { + title: 'Components/Textarea', + ...TextareaStoryArgs, +} as Meta + +const createTextarea = (args) => ( + +) + +export const Default: StoryObj = { + render: (args) => createTextarea(args), + args: { + ...TextareaComponentArgs, + }, +} + +export const Disabled: StoryObj = { + render: (args) => createTextarea(args), + args: { + ...TextareaComponentArgs, + disabled: true, + }, +} + +export const TextareaIcon: StoryObj = { + render: (args) => createTextarea(args), + args: { + ...TextareaComponentArgs, + icon: 'people', + }, +} + +export const HelperText: StoryObj = { + render: (args) => createTextarea(args), + args: { + ...TextareaComponentArgs, + helperText: 'This is a helper text example', + }, +} + +export const ErrorState: StoryObj = { + render: () => { + const [hasError, setHasError] = React.useState(false) + + const validateEmail = (email) => { + return email.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/) + } + + const validate = (value) => { + setHasError(false) + + if (value === '') return + + return validateEmail(value) ? setHasError(false) : setHasError(true) + } + + return ( + validate(ev.target.value)} + hasError={hasError} + /> + ) + }, +} + +export const WithCounter: StoryObj = { + render: () => { + return ( + + `${inputLength}/300` + } + /> + ) + }, +} diff --git a/packages/core/src/components/textarea/textarea.scss b/packages/core/src/components/textarea/textarea.scss new file mode 100644 index 000000000..3819c6c3d --- /dev/null +++ b/packages/core/src/components/textarea/textarea.scss @@ -0,0 +1,120 @@ +:host { + --atom-icon-color: var(--color-neutral-regular); + --atom-icon-grid: var(--spacing-xxxxlarge); + --atom-button-icon-size: 30px; + --atom-textarea-min-height: 57px; + display: block; + position: relative; +} + +.atom-textarea { + --ion-color-step-550: var(--color-neutral-light-1); + min-height: var(--atom-textarea-min-height); + + &[color='primary'] { + --highlight-color-focused: var(--color-brand-primary-regular); + --atom-icon-color: var(--color-brand-primary-regular); + } + + &[color='secondary'] { + --highlight-color-focused: var(--color-brand-secondary-regular); + } + + &[color='danger'] { + --highlight-color-focused: var(--color-contextual-error-regular); + } + + &.textarea-fill-solid { + &.has-focus { + --background: var(--color-neutral-light-5); + } + } + + &.textarea-disabled { + &:hover, + & { + --background: var(--color-neutral-light-2); + --color: var(--color-neutral-black); + --placeholder-color: var(--color-neutral-black); + opacity: 0.55; + } + } + + &.textarea-shape-round { + --border-radius: var(--border-radius-medium); + } + + &.has-icon { + --padding-start: var(--atom-icon-grid); + z-index: var(--zindex-10); + } + + &.has-readonly { + --highlight-color-focused: var(--color-neutral-regular); + + label, + textarea { + cursor: not-allowed; + } + + .textarea-highlight.sc-ion-textarea-md { + height: 1px; + } + } + + textarea::-webkit-calendar-picker-indicator, + textarea::-webkit-clear-button, + textarea::-webkit-search-decoration, + textarea::-webkit-search-cancel-button, + .textarea-clear-icon { + color: var(--atom-icon-color); + cursor: pointer; + position: absolute; + z-index: var(--zindex-100); + } + + textarea::-webkit-calendar-picker-indicator, + textarea::-webkit-clear-button, + textarea::-webkit-search-decoration, + textarea::-webkit-search-cancel-button { + $webkit-fine-alignment: 5px; + right: $webkit-fine-alignment; + top: 0; + } + + .textarea-clear-icon { + height: 100%; + padding: 0; + right: var(--spacing-base); + top: 0; + } +} + +.atom-icon { + color: var(--atom-icon-color); + cursor: pointer; + font-size: 16px; + left: calc(var(--atom-icon-grid) / 2); + position: absolute; + top: calc((var(--atom-textarea-min-height) / 2) + 2px); + transform: translate(-50%, -50%); + z-index: var(--zindex-100); + + .has-focus + & { + &.atom-color--primary { + --atom-icon-color: var(--color-brand-primary-regular); + } + + &.atom-color--secondary { + --atom-icon-color: var(--color-brand-secondary-regular); + } + + &.atom-color--danger { + --atom-icon-color: var(--color-contextual-error-regular); + } + } + + .textarea-disabled + & { + opacity: 0.55; + } +} diff --git a/packages/core/src/components/textarea/textarea.spec.ts b/packages/core/src/components/textarea/textarea.spec.ts new file mode 100644 index 000000000..bbafd8bc8 --- /dev/null +++ b/packages/core/src/components/textarea/textarea.spec.ts @@ -0,0 +1,209 @@ +import { newSpecPage } from '@stencil/core/testing' + +import { AtomTextarea } from './textarea' + +describe('AtomTextarea', () => { + it('renders with default values', async () => { + const page = await newSpecPage({ + components: [AtomTextarea], + html: ``, + }) + expect(page.root).toEqualHtml(` + + + + + + `) + }) + + it('renders with custom props', async () => { + const props = ` + autofocus="true" clear-input="false" clear-on-edit="false" color="secondary" disabled="true" expandable="true" fill="outline inputmode="decimal" label-placement="fixed" label="Password" mode="ios" multiple="true" name="password" pattern="[A-Za-z]{3}" placeholder="Enter password" required="true" type="password" value="test" + ` + const page = await newSpecPage({ + components: [AtomTextarea], + html: ` + + `, + }) + + expect(page.root).toEqualHtml(` + + + + + + `) + }) + + it('render with icon', async () => { + const page = await newSpecPage({ + components: [AtomTextarea], + html: ` + + `, + }) + + expect(page.root).toEqualHtml(` + + + + + + + `) + }) + + it('emits atomChange event on textarea change and atomInput event during input', async () => { + const page = await newSpecPage({ + components: [AtomTextarea], + html: '', + }) + + await page.waitForChanges() + + const textareaEl = page.root?.shadowRoot?.querySelector('ion-textarea') + const inputValue = 'Test input change' + const spy = jest.fn() + + page.root?.addEventListener('ionChange', spy) + page.root?.addEventListener('ionInput', spy) + + if (textareaEl) { + textareaEl.value = inputValue + textareaEl.dispatchEvent(new Event('ionChange')) + textareaEl.dispatchEvent(new Event('ionInput')) + } + + page.root?.dispatchEvent( + new CustomEvent('ionChange', { detail: { value: inputValue } }) + ) + + page.root?.dispatchEvent( + new CustomEvent('ionInput', { detail: { value: inputValue } }) + ) + + expect(spy).toHaveBeenCalled() + expect(spy.mock.calls[0][0].detail.value).toEqual(inputValue) + }) + + it('emits atomFocus event on textarea focus', async () => { + const page = await newSpecPage({ + components: [AtomTextarea], + html: '', + }) + + await page.waitForChanges() + + const textareaEl = page.root?.shadowRoot?.querySelector('ion-textarea') + const spy = jest.fn() + + page.root?.addEventListener('ionFocus', spy) + + if (textareaEl) { + textareaEl.dispatchEvent(new Event('ionFocus')) + } + + page.root?.dispatchEvent(new CustomEvent('ionFocus')) + + expect(spy).toHaveBeenCalled() + }) + + it('emits atomBlur event on textarea blur', async () => { + const page = await newSpecPage({ + components: [AtomTextarea], + html: '', + }) + + await page.waitForChanges() + + const textareaEl = page.root?.shadowRoot?.querySelector('ion-textarea') + const spy = jest.fn() + + page.root?.addEventListener('ionBlur', spy) + + if (textareaEl) { + textareaEl.dispatchEvent(new Event('ionBlur')) + } + + page.root?.dispatchEvent(new CustomEvent('ionBlur')) + + expect(spy).toHaveBeenCalled() + }) + + it('should remove all event listeners on disconnect', async () => { + const page = await newSpecPage({ + components: [AtomTextarea], + html: '', + }) + + await page.waitForChanges() + + const textareaEl = page.root?.shadowRoot?.querySelector('ion-textarea') + const handleChange = jest.fn() + const handleInput = jest.fn() + const handleBlur = jest.fn() + const handleFocus = jest.fn() + + if (textareaEl) { + textareaEl.addEventListener('ionChange', handleChange) + textareaEl.addEventListener('ionInput', handleInput) + textareaEl.addEventListener('ionBlur', handleBlur) + textareaEl.addEventListener('ionFocus', handleFocus) + + page.root?.shadowRoot?.removeChild(textareaEl) + page.rootInstance.disconnectedCallback() + } + + await page.waitForChanges() + + expect(handleChange).not.toHaveBeenCalled() + expect(handleInput).not.toHaveBeenCalled() + expect(handleBlur).not.toHaveBeenCalled() + expect(handleFocus).not.toHaveBeenCalled() + }) + + it('calls setFocus method correctly', async () => { + const page = await newSpecPage({ + components: [AtomTextarea], + html: ``, + }) + + await page.waitForChanges() + + const mocktextareaEl = Object.assign( + document.createElement('ion-textarea'), + { + setFocus: jest.fn(), + gettextareaElement: () => document.createElement('input'), + } + ) + + const component = page.rootInstance + component.textareaEl = mocktextareaEl + + await component.setFocus() + + expect(mocktextareaEl.setFocus).toHaveBeenCalled() + }) + + it('returns the correct textarea element', async () => { + const page = await newSpecPage({ + components: [AtomTextarea], + html: '', + }) + + await page.waitForChanges() + + const textareaEl = { + getInputElement: jest.fn(() => document.createElement('textarea')), + } + + page.rootInstance.setInputEl(textareaEl) + const returnedTextareaElement = await page.rootInstance.getInputElement() + + expect(returnedTextareaElement).toHaveProperty('tagName', 'TEXTAREA') + expect(textareaEl.getInputElement).toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/components/textarea/textarea.tsx b/packages/core/src/components/textarea/textarea.tsx new file mode 100644 index 000000000..1ae585a3a --- /dev/null +++ b/packages/core/src/components/textarea/textarea.tsx @@ -0,0 +1,196 @@ +import { Mode } from '@ionic/core' +import { JSX as IonTypes } from '@ionic/core/dist/types/components' +import { + Component, + Element, + Event, + EventEmitter, + Host, + Method, + Prop, + h, +} from '@stencil/core' + +@Component({ + tag: 'atom-textarea', + styleUrl: 'textarea.scss', + shadow: true, +}) +export class AtomTextarea { + @Element() element!: HTMLElement + + @Prop() autocomplete?: 'on' | 'off' = 'off' + @Prop() autofocus = false + @Prop() autoGrow = false + @Prop() clearOnEdit = false + @Prop() color?: 'primary' | 'secondary' | 'danger' = 'secondary' + @Prop() cols?: number + @Prop() counter = false + @Prop() counterFormatter?: ( + inputLength: number, + maxLength: number + ) => string | undefined + @Prop() debounce: number + @Prop() disabled = false + @Prop() enterKeyHint: + | 'enter' + | 'done' + | 'go' + | 'next' + | 'previous' + | 'search' + | 'send' = 'enter' + @Prop() errorText?: string + @Prop() fill: 'solid' | 'outline' = 'solid' + @Prop() hasError = false + @Prop() helperText?: string + @Prop() icon?: string + @Prop() inputmode?: + | 'none' + | 'text' + | 'tel' + | 'url' + | 'email' + | 'numeric' + | 'decimal' + | 'search' + + @Prop() label?: string + @Prop() labelPlacement?: 'stacked' | 'floating' = 'floating' + @Prop() maxlength?: number + @Prop() minlength?: number + @Prop() mode: Mode = 'md' + @Prop() name?: string + @Prop() pattern?: string + @Prop() placeholder?: string + @Prop() readonly = false + @Prop() required = false + @Prop() rows = 4 + @Prop() shape: 'round' | undefined = 'round' + @Prop() step?: string + @Prop({ mutable: true, reflect: true }) value?: IonTypes.IonTextarea['value'] + @Prop() wrap?: 'hard' | 'soft' | 'off' + + @Event() atomFocus!: EventEmitter + @Event() atomBlur!: EventEmitter + @Event() atomChange!: EventEmitter + @Event() atomInput!: EventEmitter + + private _textareaEl!: HTMLIonTextareaElement + + get textareaEl(): HTMLIonTextareaElement { + return this._textareaEl + } + + set textareaEl(value: HTMLIonTextareaElement) { + this._textareaEl = value + } + + @Method() + async setFocus() { + await this.textareaEl.setFocus() + } + + @Method() + async setInputEl(textareaEl: HTMLIonTextareaElement) { + this._textareaEl = textareaEl + } + + @Method() async getInputElement() { + return this.textareaEl.getInputElement() + } + + componentDidLoad() { + this.textareaEl.addEventListener('ionChange', this.handleChange) + this.textareaEl.addEventListener('ionInput', this.handleInput) + this.textareaEl.addEventListener('ionBlur', this.handleBlur) + this.textareaEl.addEventListener('ionFocus', this.handleFocus) + } + + disconnectedCallback() { + this.textareaEl.removeEventListener('ionChange', this.handleChange) + this.textareaEl.removeEventListener('ionInput', this.handleInput) + this.textareaEl.removeEventListener('ionBlur', this.handleBlur) + this.textareaEl.removeEventListener('ionFocus', this.handleFocus) + } + + private handleChange: IonTypes.IonTextarea['onIonChange'] = (event) => { + const value = event.target.value + this.value = value + this.atomChange.emit(String(value)) + } + + private handleInput: IonTypes.IonTextarea['onIonInput'] = (event) => { + const value = event.target.value + this.value = value + this.atomInput.emit(String(value)) + } + + private handleBlur = () => { + this.textareaEl.removeEventListener('ionBlur', this.handleBlur) + this.atomBlur.emit() + } + + private handleFocus = () => { + this.textareaEl.removeEventListener('ionFocus', this.handleFocus) + this.atomFocus.emit() + } + + render(): JSX.Element { + return ( + + + (this.textareaEl = el as unknown as HTMLIonTextareaElement) + } + class={{ + 'atom-textarea': true, + 'ion-invalid ion-touched': this.hasError, + 'has-icon': !!this.icon, + 'has-readonly': this.readonly, + }} + autoGrow={this.autoGrow} + autofocus={this.autofocus} + clearOnEdit={this.clearOnEdit} + cols={this.cols} + color={this.color} + counter={this.counter} + counterFormatter={this.counterFormatter} + disabled={this.disabled} + debounce={this.debounce} + enterKeyHint={this.enterKeyHint} + errorText={this.errorText} + fill={this.fill} + helperText={this.helperText} + inputmode={this.inputmode} + label={this.label} + labelPlacement={this.labelPlacement} + maxlength={this.maxlength} + minlength={this.minlength} + mode={this.mode} + name={this.name} + placeholder={this.placeholder} + readonly={this.readonly} + rows={this.rows} + required={this.required} + shape={this.shape} + value={this.value} + wrap={this.wrap} + onIonChange={this.handleChange} + onIonInput={this.handleInput} + onIonBlur={this.handleBlur} + onIonFocus={this.handleFocus} + /> + {this.icon && ( + + )} + + ) + } +} diff --git a/sonar-project.properties b/sonar-project.properties index 59b1ab528..7f9f997c1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -12,6 +12,8 @@ sonar.coverage.exclusions=\ utils/**,\ apps/**,\ packages/icons/** +sonar.cpd.exclusions=\ +packages/core/src/components/textarea/** sonar.exclusions=\ node_modules/**,\ **/dist/**,\