From a55e20864e9cb5a50252d99e072f8b1808591d96 Mon Sep 17 00:00:00 2001 From: Semih Ozker <52029025+ozkersemih@users.noreply.github.com> Date: Thu, 2 Feb 2023 18:10:19 +0300 Subject: [PATCH] feat: textarea component (#347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: semih.ozker Co-authored-by: Murat Çorlu <127687+muratcorlu@users.noreply.github.com> --- commitlint.config.js | 1 + src/baklava.ts | 1 + src/components/button/bl-button.test.ts | 1 + src/components/textarea/bl-textarea.css | 193 +++++++++++++ .../textarea/bl-textarea.stories.mdx | 256 +++++++++++++++++ src/components/textarea/bl-textarea.test.ts | 230 ++++++++++++++++ src/components/textarea/bl-textarea.ts | 259 ++++++++++++++++++ src/utilities/form-control.test.ts | 59 +++- src/utilities/form-control.ts | 19 ++ 9 files changed, 1009 insertions(+), 10 deletions(-) create mode 100644 src/components/textarea/bl-textarea.css create mode 100644 src/components/textarea/bl-textarea.stories.mdx create mode 100644 src/components/textarea/bl-textarea.test.ts create mode 100644 src/components/textarea/bl-textarea.ts diff --git a/commitlint.config.js b/commitlint.config.js index 7753dd2a..0c60949c 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -25,6 +25,7 @@ module.exports = { 'drawer', 'dropdown', 'switch', + 'textarea', ], ], }, diff --git a/src/baklava.ts b/src/baklava.ts index 89ddc94c..9bebaf92 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -16,6 +16,7 @@ export { default as BlSelectOption } from './components/select/option/bl-select- export { default as BlTab } from './components/tab-group/tab/bl-tab'; export { default as BlTabGroup } from './components/tab-group/bl-tab-group'; export { default as BlTabPanel } from './components/tab-group/tab-panel/bl-tab-panel'; +export { default as BlTextarea } from './components/textarea/bl-textarea'; export { default as BlTooltip } from './components/tooltip/bl-tooltip'; export { default as BlDropdown } from './components/dropdown/bl-dropdown'; export { default as BlDropdownItem } from './components/dropdown/item/bl-dropdown-item'; diff --git a/src/components/button/bl-button.test.ts b/src/components/button/bl-button.test.ts index 65d1fe47..e61561d0 100644 --- a/src/components/button/bl-button.test.ts +++ b/src/components/button/bl-button.test.ts @@ -13,6 +13,7 @@ describe('bl-button', () => { it('renders with default values', async () => { const el = await fixture(html``); + assert.shadowDom.equal( el, ` diff --git a/src/components/textarea/bl-textarea.css b/src/components/textarea/bl-textarea.css new file mode 100644 index 00000000..2a4ac5a1 --- /dev/null +++ b/src/components/textarea/bl-textarea.css @@ -0,0 +1,193 @@ +:host { + display: inline-block; + width: 200px; + position: relative; +} + +.wrapper { + --row-count: 1; + --maxrow-count: ; + --line-height: var(--bl-font-title-3-line-height); + --scroll-height: var(--line-height); + --padding-vertical: var(--bl-size-2xs); + --padding-horizontal: var(--bl-size-xs); + --border-size: 1px; + --default-scroll-height: calc((var(--row-count) * var(--line-height)) + var(--padding-vertical)); + --height: max(var(--scroll-height), var(--default-scroll-height)); + --input-font: var(--bl-font-body-text-2); + --border-radius: var(--bl-size-3xs); + + display: flex; + flex-direction: column; + position: relative; + gap: var(--bl-size-3xs); +} + +.input-wrapper { + border: solid var(--border-size) var(--bl-color-border); + border-radius: var(--border-radius); + padding-top: var(--padding-vertical); + display: flex; + box-sizing: border-box; +} + +textarea { + width: 100%; + align-self: stretch; + outline: none; + font: var(--input-font); + padding: 0 calc(var(--padding-horizontal) - var(--border-size)); + padding-bottom: var(--padding-vertical); + margin: 0; + border: none; + border-radius: var(--border-radius); + color: var(--bl-color-content-primary); + resize: vertical; + display: block; +} + +:host([size='large']) .wrapper { + --padding-vertical: var(--bl-size-xs); + --padding-horizontal: var(--bl-size-m); +} + +:host([size='small']) .wrapper { + --padding-vertical: var(--bl-size-3xs); + --padding-horizontal: var(--bl-size-xs); + --input-font: var(--bl-font-body-text-3); + --line-height: var(--bl-font-title-4-line-height); +} + +textarea:disabled { + background-color: var(--bl-color-secondary-background); + color: var(--bl-color-content-tertiary); + cursor: not-allowed; +} + +:host([disabled]) .wrapper { + background-color: var(--bl-color-secondary-background); +} + +:host([expand]) textarea { + overflow: hidden; + resize: none; + height: var(--height); +} + +:host([expand][max-rows]) textarea { + --maxrow-height: calc((var(--maxrow-count) * var(--line-height)) + var(--padding-vertical)); + + overflow-y: scroll; + height: min(var(--height), var(--maxrow-height)); +} + +.wrapper:focus-within { + border-color: var(--bl-color-primary); +} + +.dirty.max-len-invalid, +.dirty.invalid { + border-color: var(--bl-color-danger); +} + +:host([label]) ::placeholder { + color: transparent; + transition: color ease-out 0.4s; +} + +label { + position: absolute; + top: var(--padding-vertical); + left: var(--padding-horizontal); + right: var(--padding-horizontal); + transition: all ease-in 0.2s; + pointer-events: none; + font: var(--bl-font-title-3-regular); + color: var(--bl-color-content-tertiary); + padding: 0; + max-width: max-content; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +:where(.wrapper:focus-within, .wrapper.has-value) label { + --label-padding: var(--bl-size-3xs); + + top: 0; + left: calc(var(--padding-horizontal) - var(--label-padding)); + transform: translateY(-50%); + font: var(--bl-font-caption); + color: var(--bl-color-content-secondary); + padding: 0 var(--label-padding); + background-color: var(--bl-color-content-primary-contrast); + pointer-events: initial; +} + +:host([label-fixed]) { + padding-top: var(--bl-size-m); +} + +:host ::placeholder, +:host([label-fixed]) ::placeholder { + color: var(--bl-color-content-tertiary); +} + +:host([label-fixed]) label { + position: static; + transition: none; + transform: none; + pointer-events: initial; + font: var(--bl-font-caption); + color: var(--bl-color-content-secondary); + background-color: initial; + padding: 0; +} + +.hint { + display: none; + font: var(--bl-font-body-text-3); +} + +:host([character-counter]) .hint, +:host([help-text]) .hint, +.dirty.invalid .hint { + display: flex; + gap: var(--bl-size-3xs); +} + +.hint > * { + margin: 0; + padding: 0; +} + +.help-text, +.invalid-text { + flex: 1; +} + +.counter-text { + color: var(--bl-color-content-secondary); + margin-left: auto; +} + +:where(.max-len-invalid, .dirty.invalid) .hint > .counter-text { + color: var(--bl-color-danger); +} + +.invalid-text { + display: none; + color: var(--bl-color-danger); +} + +.help-text { + color: var(--bl-color-content-secondary); +} + +:where(.dirty.max-len-invalid, .dirty.invalid) .hint > .invalid-text { + display: inline-block; +} + +.dirty.invalid .hint > .help-text { + display: none; +} diff --git a/src/components/textarea/bl-textarea.stories.mdx b/src/components/textarea/bl-textarea.stories.mdx new file mode 100644 index 00000000..b2ff509a --- /dev/null +++ b/src/components/textarea/bl-textarea.stories.mdx @@ -0,0 +1,256 @@ +import { Meta, Canvas, ArgsTable, Story } from "@storybook/addon-docs"; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + + + + +export const TextareaTemplate = (args) => html` +` + +# Textarea + +[ADR](https://github.com/Trendyol/baklava/issues/145) +[Figma](https://www.figma.com/file/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?node-id=163%3A2243) + +Textarea component is the component to take multi-line text input from user. + +## Basic Usage + +Textarea supports using `label` and `placeholder`. You can set a initial `value` and set it `disabled` if you need. + + + + {TextareaTemplate.bind()} + + + {TextareaTemplate.bind()} + + + {TextareaTemplate.bind()} + + + {TextareaTemplate.bind()} + + + +## Textarea Sizes + +Textarea has 3 size options: `small`, `medium` and `large`. Size is `medium` by default. + + + + {TextareaTemplate.bind()} + + + {TextareaTemplate.bind()} + + + {TextareaTemplate.bind()} + + + +## Textarea Labels + +Textarea optionally can have a label. If the label is set, it will be a floating label by default. If you want to use always it on top of the textarea, then you can use `label-fixed` attribute. + + + + {TextareaTemplate.bind({})} + + + {TextareaTemplate.bind({})} + + + {TextareaTemplate.bind({})} + + + +Textarea component will cut-out long labels those doesn't fit width of textarea, with ellipsis char. + + + + {TextareaTemplate.bind({})} + + + {TextareaTemplate.bind({})} + + + {TextareaTemplate.bind({})} + + + +## Use with row attribute + +You can set minimum `rows` of textarea to set vertical area. + + + + {TextareaTemplate.bind()} + + + {TextareaTemplate.bind()} + + + +## Textarea Expand + +Textarea can be used as auto expanding by content. You can enable this feature by adding `expand` attribute. +Also you can limit expanding with `max-rows` attribute. + + + + {TextareaTemplate.bind()} + + + {TextareaTemplate.bind()} + + + +## Textarea Help Text + +You can give extra information to user with `help-text` attribute. + + + + {TextareaTemplate.bind({})} + + + {TextareaTemplate.bind({})} + + + +## Textarea Character Counter + +You can display character counter by setting `character-counter` attribute. By doing this you can give direct feedback to the user about message length. +If you use it together with `maxlength` attribute user can have immediate attention if they exceed allowed text length. + + + + {TextareaTemplate.bind({})} + + + {TextareaTemplate.bind({})} + + + +## Textarea Validation + +Textarea supports native HTML validation rules like `required`, `minlength`, `maxlength`. Validation feedback becomes active after user finishes entering text (by leaving textarea). +Containing form submit also triggers validation. By default it uses browsers native error messages. You can override error message with `invalid-text` attribute. + + + + {TextareaTemplate.bind({})} + + + {TextareaTemplate.bind({})} + + + {TextareaTemplate.bind({})} + + + +## Using within a form + +Textarea component uses [ElementInternals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) to associate with it's parent form automatically. +When you use `bl-textarea` within a form with a `name` attribute, textarea's value will be automatically set parent form's FormData. Check the example below: + +```html +
+ + + +
+ + +``` + +## Reference + + + diff --git a/src/components/textarea/bl-textarea.test.ts b/src/components/textarea/bl-textarea.test.ts new file mode 100644 index 00000000..887b58ba --- /dev/null +++ b/src/components/textarea/bl-textarea.test.ts @@ -0,0 +1,230 @@ +import { assert, elementUpdated, expect, fixture, html, oneEvent } from '@open-wc/testing'; +import BlTextarea from './bl-textarea'; +import { sendKeys } from '@web/test-runner-commands'; + +describe('bl-textarea', () => { + it('is defined', () => { + const el = document.createElement('bl-textarea'); + assert.instanceOf(el, BlTextarea); + }); + + it('renders with default values', async () => { + const el = await fixture(html``); + assert.shadowDom.equal( + el, + ` +
+ +
+ +
+
+
+ ` + ); + }); + + it('should set label', async () => { + const labelText = 'Some Label'; + const el = await fixture(html``); + const label = el.shadowRoot?.querySelector('label'); + expect(label).to.exist; + expect(label?.innerText).to.equal(labelText); + }); + + it('should set help text', async () => { + const helpText = 'Some help text'; + const el = await fixture(html``); + const helpMessage = el.shadowRoot?.querySelector('.help-text'); + expect(helpMessage).to.exist; + expect(helpMessage?.innerText).to.equal(helpText); + }); + + it('should set character counter', async () => { + const el = await fixture( + html`` + ); + const characterCounter = el.shadowRoot?.querySelector('.counter-text'); + + expect(characterCounter?.innerText).to.equal('5'); + }); + + it('should set character counter with maxlength', async () => { + const el = await fixture( + html`` + ); + const characterCounter = el.shadowRoot?.querySelector('.counter-text'); + + expect(characterCounter?.innerText).to.equal('5/10'); + }); + + it('should increase rows attribute dynamically', async () => { + const el = await fixture(html``); + el.setAttribute('rows', '2'); + + expect(el?.getAttribute('rows')).to.equal('2'); + }); + + it('should decrease rows attribute dynamically', async () => { + const el = await fixture(html``); + el.setAttribute('rows', '1'); + + expect(el?.getAttribute('rows')).to.equal('1'); + }); + + it('should expand when input text is longer than one row', async () => { + const el = await fixture( + html`` + ); + const textarea = el.shadowRoot?.querySelector('textarea'); + + await textarea?.focus(); + + await sendKeys({ + type: 'some dummy text some dummy text some dummy text some dummy text some dummy text some dummy text some dummy text some dummy text', + }); + + const height = getComputedStyle(el.validationTarget).height; + + expect(height).to.equal(`${el.validationTarget.scrollHeight}px`); + }); + + it('should have same heights if they have same max-rows', async () => { + const el = await fixture( + html`` + ); + const el2 = await fixture( + html`` + ); + const textarea = el.shadowRoot?.querySelector('textarea'); + const textarea2 = el2.shadowRoot?.querySelector('textarea'); + + await textarea?.focus(); + + await sendKeys({ + type: + 'some dummy text some dummy text some dummy text some dummy text' + + ' some dummy text some dummy text some dummy text some dummy text' + + 'some dummy text some dummy text some dummy text some dummy text', + }); + + await textarea2?.focus(); + + await sendKeys({ + type: 'some dummy text some dummy text some dummy text some dummy text', + }); + + const height = getComputedStyle(el.validationTarget).height; + const heightThreeRows = getComputedStyle(el2.validationTarget).height; + + expect(height).to.equal(heightThreeRows); + }); + + describe('validation', () => { + it('should be valid by default', async () => { + const el = await fixture(html``); + expect(el.validity.valid).to.be.true; + }); + it('should be invalid with required attribute', async () => { + const el = await fixture(html``); + expect(el.validity.valid).to.be.false; + }); + it('should be valid with required when value is filled', async () => { + const el = await fixture( + html`` + ); + expect(el.validity.valid).to.be.true; + }); + it('should set custom invalid text', async () => { + const customErrorMsg = 'This field is mandatory'; + const el = await fixture( + html`` + ); + + el.reportValidity(); + + await elementUpdated(el); + + const errorMsgElement = el.shadowRoot?.querySelector('.invalid-text'); + + expect(el.validity.valid).to.be.false; + + expect(errorMsgElement).to.exist; + expect(errorMsgElement.innerText).to.equal(customErrorMsg); + }); + }); + + describe('events', () => { + it('should fire bl-input event when user enters a value', async () => { + const el = await fixture(html``); + const textarea = el.shadowRoot?.querySelector('textarea'); + + if (textarea) textarea.value = 'some value'; + + setTimeout(() => textarea?.dispatchEvent(new Event('input'))); + + const ev = await oneEvent(el, 'bl-input'); + expect(ev).to.exist; + expect(ev.detail).to.be.equal('some value'); + }); + it('should fire bl-input event when input value changes', async () => { + const el = await fixture(html``); + const textarea = el.shadowRoot?.querySelector('textarea'); + + if (textarea) textarea.value = 'some value'; + + setTimeout(() => textarea?.dispatchEvent(new Event('change'))); + + const ev = await oneEvent(el, 'bl-change'); + expect(ev).to.exist; + expect(ev.detail).to.be.equal('some value'); + }); + it('should fire bl-invalid event when input value not correct', async () => { + const el = await fixture(html``); + const textarea = el.shadowRoot?.querySelector('textarea'); + + await textarea?.focus(); + + await sendKeys({ + type: 'a text more than five characters', + }); + + setTimeout(() => textarea?.dispatchEvent(new Event('invalid'))); + + const ev = await oneEvent(el, 'bl-invalid'); + expect(ev).to.exist; + expect(ev.detail['rangeOverflow']).to.equal(true); + }); + }); + describe('form integration', () => { + it('should show errors when parent form is submitted', async () => { + const form = await fixture(html`
+ +
`); + const blTextarea = form.querySelector('bl-textarea'); + + form.addEventListener('submit', e => e.preventDefault()); + + form.dispatchEvent(new SubmitEvent('submit', { cancelable: true })); + + await elementUpdated(form); + + const errorMessageElement = ( + blTextarea?.shadowRoot?.querySelector('.invalid-text') + ); + + expect(blTextarea?.validity.valid).to.be.false; + + expect(errorMessageElement).to.exist; + }); + }); +}); diff --git a/src/components/textarea/bl-textarea.ts b/src/components/textarea/bl-textarea.ts new file mode 100644 index 00000000..e6aceee7 --- /dev/null +++ b/src/components/textarea/bl-textarea.ts @@ -0,0 +1,259 @@ +import { CSSResultGroup, html, LitElement, PropertyValues, TemplateResult } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { FormControlMixin } from '@open-wc/form-control'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { event, EventDispatcher } from '../../utilities/event'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { live } from 'lit/directives/live.js'; +import { textAreaValidators } from '../../utilities/form-control'; +import 'element-internals-polyfill'; +import style from './bl-textarea.css'; + +export type TextareaSize = 'small' | 'medium' | 'large'; +/** + * @tag bl-textarea + * @summary Baklava Textarea component + */ +@customElement('bl-textarea') +export default class BlTextarea extends FormControlMixin(LitElement) { + static get styles(): CSSResultGroup { + return [style]; + } + + static formControlValidators = textAreaValidators; + + @query('textarea') + validationTarget: HTMLTextAreaElement; + + /** + * Name of textarea + */ + @property({ type: String }) + name = ''; + + /** + * Makes textarea a mandatory field + */ + @property({ type: Boolean }) + required = false; + + /** + * Disables the textarea + */ + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Sets expandity + */ + @property({ type: Boolean, reflect: true }) + expand = false; + + /** + * Sets max row when expand is true + */ + @property({ type: Number, reflect: true, attribute: 'max-rows' }) + maxRows?: number; + + /** + * Sets textarea size. + */ + @property({ type: String, reflect: true }) + size?: TextareaSize = 'medium'; + + /** + * Sets label of the textarea + */ + @property({ reflect: true }) + label?: string; + + /** + * Makes label as fixed positioned + */ + @property({ type: Boolean, attribute: 'label-fixed', reflect: true }) + labelFixed = false; + + /** + * Sets placeholder of the textarea + */ + @property({}) + placeholder?: string; + + /** + * Enables showing character counter. + */ + @property({ type: Boolean, attribute: 'character-counter' }) + characterCounter = false; + + /** + * Adds help text + */ + @property({ type: String, attribute: 'help-text' }) + helpText?: string; + + /** + * Set custom error message + */ + @property({ type: String, attribute: 'invalid-text' }) + customInvalidText?: string; + + /** + * Sets minimum length of the textarea + */ + @property({ type: Number }) + minlength?: number; + + /** + * Sets max length of textarea + */ + @property({ type: Number }) + maxlength?: number; + + /** + * Sets initial value of the textarea + */ + @property() + value = ''; + + /** + * Sets textarea visible row count. + */ + @property({ type: Number }) + rows?: number = 4; + + @event('bl-input') private onInput: EventDispatcher; + + @event('bl-change') private onChange: EventDispatcher; + + @event('bl-invalid') private onInvalid: EventDispatcher; + + @state() + private customScrollHeight: string | null = null; + + connectedCallback() { + super.connectedCallback(); + this.internals.form?.addEventListener('submit', () => { + this.reportValidity(); + }); + } + + private onError = (): void => { + this.onInvalid(this.internals.validity); + }; + + private inputHandler(event: Event) { + this.autoResize(); + + const value = (event.target as HTMLTextAreaElement).value; + this.setValue(value); + this.onInput(value); + } + + private changeHandler(event: Event) { + const value = (event.target as HTMLTextAreaElement).value; + + this.dirty = true; + this.setValue(value); + this.onChange(value); + } + + firstUpdated() { + this.setValue(this.value); + this.autoResize(); + } + + protected updated(changedProperties: PropertyValues) { + if (changedProperties.has('rows')) { + this.autoResize(); + } + } + + reportValidity() { + this.dirty = true; + return this.checkValidity(); + } + + valueChangedCallback(value: string): void { + this.value = value; + } + + validityCallback(): string | void { + return this.customInvalidText || this.validationTarget?.validationMessage; + } + + private autoResize() { + if (!this.expand) { + return; + } + + this.validationTarget.style.height = 'auto'; + const scrollHeight = this.validationTarget.scrollHeight; + this.customScrollHeight = `${scrollHeight}px`; + this.validationTarget.style.removeProperty('height'); + } + + @state() private dirty = false; + + render(): TemplateResult { + const maxLengthInvalid = this.internals.validity.rangeOverflow; + const invalidMessage = !this.checkValidity() + ? html`

${this.validationMessage}

` + : ``; + const helpMessage = + this.helpText ? html`

${this.helpText}

` : ``; + + const label = this.label ? html`` : ''; + const characterCounterText = + this.characterCounter && this.maxlength + ? `${this.value.length}/${this.maxlength}` + : this.characterCounter + ? `${this.value.length}` + : ''; + const characterCounter = this.characterCounter + ? html`

${characterCounterText}

` + : ''; + + const wrapperClasses = { + 'wrapper': true, + 'has-value': this.value !== null && this.value !== '', + 'dirty': this.dirty, + 'max-len-invalid': maxLengthInvalid, + 'invalid': !this.checkValidity(), + }; + + const styles = { + '--row-count': `${this.rows}`, + '--maxrow-count': this.maxRows ? `${this.maxRows}` : null, + '--scroll-height': this.customScrollHeight, + }; + + return html` +
+ ${label} +
+ +
+
${invalidMessage}${helpMessage}${characterCounter}
+
+ `; + } +} +declare global { + interface HTMLElementTagNameMap { + 'bl-textarea': BlTextarea; + } +} diff --git a/src/utilities/form-control.test.ts b/src/utilities/form-control.test.ts index 9287ec42..0fcca40a 100644 --- a/src/utilities/form-control.test.ts +++ b/src/utilities/form-control.test.ts @@ -1,7 +1,7 @@ import { elementUpdated, expect, fixture, fixtureCleanup } from "@open-wc/testing"; import { html, LitElement } from "lit"; import { customElement, query } from "lit/decorators.js"; -import { innerInputValidators } from "./form-control" +import {innerInputValidators, textareaLengthValidator} from "./form-control" @customElement('my-valid-input') class MyValidInput extends LitElement { @@ -19,23 +19,62 @@ class MyInvalidInput extends LitElement { } } + + describe('Form Control Validators', () => { - afterEach(fixtureCleanup); + describe('innerInputValidators', () => { + afterEach(fixtureCleanup); + + it('should return true if validationTarget is not present', async () => { + + const el = await fixture(html``); - it('should return true if validationTarget is not present', async () => { + expect(innerInputValidators.every(validator => validator.isValid(el))).to.be.true; + }); - const el = await fixture(html``); + it('should return correct value if validationTarget present', async () => { + const el = await fixture(html``); - expect(innerInputValidators.every(validator => validator.isValid(el))).to.be.true; + await elementUpdated(el); + + expect(innerInputValidators.every(validator => validator.isValid(el))).to.be.false; + expect(innerInputValidators.find(validator => !validator.isValid(el))?.key).to.eq('valueMissing'); + + }); }); - it('should return correct value if validationTarget present', async () => { - const el = await fixture(html``); + describe('textareaLengthValidator', () => { + @customElement('my-valid-textarea') + class MyValidTextarea extends LitElement { + validationTarget: HTMLTextAreaElement; + } + - await elementUpdated(el); + @customElement('my-invalid-textarea') + class MyInvalidTextarea extends LitElement { + @query('textarea') + validationTarget: HTMLTextAreaElement; - expect(innerInputValidators.every(validator => validator.isValid(el))).to.be.false; - expect(innerInputValidators.find(validator => !validator.isValid(el))?.key).to.eq('valueMissing'); + render() { + return html`` + } + } + afterEach(fixtureCleanup); + + it('should return true if validationTarget is not present', async () => { + + const el = await fixture(html``); + + expect(textareaLengthValidator.isValid(el)).to.be.true; + }); + + it('should return true if validationTarget is not present', async () => { + + const el = await fixture(html``); + + expect(textareaLengthValidator.isValid(el)).to.be.false; + }); }); + }); diff --git a/src/utilities/form-control.ts b/src/utilities/form-control.ts index f6cd2b3b..6cfe9f7b 100644 --- a/src/utilities/form-control.ts +++ b/src/utilities/form-control.ts @@ -1,3 +1,6 @@ +import {maxLengthValidator, requiredValidator} from "@open-wc/form-control"; + + const validityStates: Array = [ 'valueMissing', 'typeMismatch', @@ -18,3 +21,19 @@ export const innerInputValidators = validityStates.map(key => ({ return true; }, })); + +export const textareaLengthValidator = { + ...maxLengthValidator, + isValid(instance: HTMLElement & { validationTarget: HTMLTextAreaElement }) { + if(instance.validationTarget && instance.attributes.getNamedItem('maxLength')){ + return (Number(instance.attributes.getNamedItem('maxlength')?.value) >= instance.validationTarget.value.length); + } + return true; + } +}; + +export const textAreaValidators = [ + ...innerInputValidators, + textareaLengthValidator, + requiredValidator +]