diff --git a/.changeset/cuddly-jobs-agree.md b/.changeset/cuddly-jobs-agree.md new file mode 100644 index 0000000000..62566de250 --- /dev/null +++ b/.changeset/cuddly-jobs-agree.md @@ -0,0 +1,5 @@ +--- +"pie-storybook": minor +--- + +[Added] - Character count and label to pie-textarea diff --git a/.changeset/rotten-pens-arrive.md b/.changeset/rotten-pens-arrive.md new file mode 100644 index 0000000000..7d0afdda38 --- /dev/null +++ b/.changeset/rotten-pens-arrive.md @@ -0,0 +1,5 @@ +--- +"@justeattakeaway/pie-form-label": minor +--- + +[Added] - data test id to leading and trailing label content diff --git a/.changeset/seven-fishes-push.md b/.changeset/seven-fishes-push.md new file mode 100644 index 0000000000..2fa4d45391 --- /dev/null +++ b/.changeset/seven-fishes-push.md @@ -0,0 +1,5 @@ +--- +"@justeattakeaway/pie-textarea": minor +--- + +[Added] - Character count and label diff --git a/apps/pie-storybook/stories/pie-textarea.stories.ts b/apps/pie-storybook/stories/pie-textarea.stories.ts index d2181493c7..6dce471c37 100644 --- a/apps/pie-storybook/stories/pie-textarea.stories.ts +++ b/apps/pie-storybook/stories/pie-textarea.stories.ts @@ -29,6 +29,8 @@ const Template = ({ name, autocomplete, autoFocus, + label, + maxLength, }: TextareaProps) => { const [, updateArgs] = UseArgs(); @@ -60,6 +62,8 @@ const Template = ({ ?autoFocus="${autoFocus}" ?readonly="${readonly}" ?required="${required}" + maxLength="${ifDefined(maxLength)}" + label="${ifDefined(label)}" @input="${onInput}" @change="${onChange}"> @@ -135,6 +139,20 @@ const textareaStoryMeta: TextareaStoryMeta = { summary: 'off', }, }, + label: { + description: 'The label for the textarea field.', + control: 'text', + defaultValue: { + summary: defaultProps.label, + }, + }, + maxLength: { + description: 'The maximum number of characters allowed in the textarea field.', + control: 'number', + defaultValue: { + summary: 0, + }, + }, }, args: defaultArgs, parameters: { diff --git a/packages/components/pie-form-label/src/index.ts b/packages/components/pie-form-label/src/index.ts index c9554ef3e6..4bfc5ef527 100644 --- a/packages/components/pie-form-label/src/index.ts +++ b/packages/components/pie-form-label/src/index.ts @@ -60,10 +60,10 @@ export class PieFormLabel extends RtlMixin(LitElement) implements FormLabelProps for=${ifDefined(this.for)}>
${isRTL ? this._renderOptionalLabel() : nothing} - + ${!isRTL ? this._renderOptionalLabel() : nothing}
- ${trailing ? html`${trailing}` : nothing} + ${trailing ? html`${trailing}` : nothing} `; } diff --git a/packages/components/pie-textarea/package.json b/packages/components/pie-textarea/package.json index 2c6747f64b..da011b35ee 100644 --- a/packages/components/pie-textarea/package.json +++ b/packages/components/pie-textarea/package.json @@ -41,6 +41,7 @@ "cem-plugin-module-file-extensions": "0.0.5" }, "dependencies": { + "@justeattakeaway/pie-form-label": "0.13.6", "@justeattakeaway/pie-webc-core": "0.24.0", "lodash.throttle": "4.1.1" }, diff --git a/packages/components/pie-textarea/src/defs.ts b/packages/components/pie-textarea/src/defs.ts index 060f71cee3..b0b7d613cc 100644 --- a/packages/components/pie-textarea/src/defs.ts +++ b/packages/components/pie-textarea/src/defs.ts @@ -54,12 +54,23 @@ export interface TextareaProps { * If true, the textarea is required to have a value before submitting the form. If there is no value, then the component validity state will be invalid. */ required?: boolean; + + /** + * The label text for the textarea field. + */ + label?: string; + + /** + * The maximum number of characters allowed in the textarea field. + * If the `label` property is not set, this property will have no effect. + */ + maxLength?: number; } /** * The default values for the `TextareaProps` that are required (i.e. they have a fallback value in the component). */ -type DefaultProps = ComponentDefaultProps>; +type DefaultProps = ComponentDefaultProps>; /** * Default values for optional properties that have default fallback values in the component. @@ -68,6 +79,7 @@ export const defaultProps: DefaultProps = { disabled: false, size: 'medium', resize: 'auto', + label: '', value: '', autoFocus: false, readonly: false, diff --git a/packages/components/pie-textarea/src/index.ts b/packages/components/pie-textarea/src/index.ts index ead29a9fca..2339d7bf1e 100644 --- a/packages/components/pie-textarea/src/index.ts +++ b/packages/components/pie-textarea/src/index.ts @@ -1,8 +1,8 @@ import { - LitElement, html, unsafeCSS, PropertyValues, + LitElement, html, unsafeCSS, PropertyValues, nothing, } from 'lit'; + import { property, query } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; import { live } from 'lit/directives/live.js'; import throttle from 'lodash.throttle'; @@ -10,11 +10,14 @@ import { validPropertyValues, RtlMixin, defineCustomElement, FormControlMixin, wrapNativeEvent, } from '@justeattakeaway/pie-webc-core'; +import { ifDefined } from 'lit/directives/if-defined.js'; import styles from './textarea.scss?inline'; import { TextareaProps, defaultProps, sizes, resizeModes, } from './defs'; +import '@justeattakeaway/pie-form-label'; + // Valid values available to consumers export * from './defs'; @@ -42,6 +45,12 @@ export class PieTextarea extends FormControlMixin(RtlMixin(LitElement)) implemen @validPropertyValues(componentSelector, resizeModes, defaultProps.resize) public resize = defaultProps.resize; + @property({ type: String }) + public label = defaultProps.label; + + @property({ type: Number }) + public maxLength: TextareaProps['maxLength']; + @property({ type: Boolean }) public readonly = defaultProps.readonly; @@ -96,6 +105,7 @@ export class PieTextarea extends FormControlMixin(RtlMixin(LitElement)) implemen } protected firstUpdated (): void { + this.restrictInputLength(); this._internals.setFormValue(this.value); this._textarea.addEventListener('keydown', this.handleKeyDown); @@ -105,14 +115,25 @@ export class PieTextarea extends FormControlMixin(RtlMixin(LitElement)) implemen this._throttledResize(); } - protected updated (changedProperties: PropertyValues) { - if (this.resize === 'auto' && (changedProperties.has('resize') || changedProperties.has('size'))) { - this.handleResize(); + private restrictInputLength () { + if (this.label.length && this.maxLength && this.value.length > this.maxLength) { + const trimmedValue = this.value.slice(0, this.maxLength); + // Ensures that the internal text area is correctly trimmed and synced with our value. + // The live() directive does not solve this for us. + this._textarea.value = trimmedValue; + this.value = trimmedValue; } + } + protected updated (changedProperties: PropertyValues) { if (changedProperties.has('value')) { + this.restrictInputLength(); this._internals.setFormValue(this.value); } + + if (this.resize === 'auto' && (changedProperties.has('resize') || changedProperties.has('size'))) { + this.handleResize(); + } } /** @@ -121,6 +142,7 @@ export class PieTextarea extends FormControlMixin(RtlMixin(LitElement)) implemen */ private handleInput = (event: InputEvent) => { this.value = (event.target as HTMLTextAreaElement).value; + this.restrictInputLength(); this._internals.setFormValue(this.value); this.handleResize(); @@ -146,6 +168,14 @@ export class PieTextarea extends FormControlMixin(RtlMixin(LitElement)) implemen this._textarea.removeEventListener('keydown', this.handleKeyDown); } + renderLabel (label: string, maxLength?: number) { + const characterCount = maxLength ? `${this.value.length}/${maxLength}` : undefined; + + return label?.length + ? html`${label}` + : nothing; + } + render () { const { disabled, @@ -157,6 +187,8 @@ export class PieTextarea extends FormControlMixin(RtlMixin(LitElement)) implemen readonly, value, required, + label, + maxLength, } = this; return html` @@ -165,7 +197,10 @@ export class PieTextarea extends FormControlMixin(RtlMixin(LitElement)) implemen data-test-id="pie-textarea-wrapper" data-pie-size="${size}" data-pie-resize="${resize}"> + ${this.renderLabel(label, maxLength)} `; } diff --git a/packages/components/pie-textarea/test/component/pie-textarea.spec.ts b/packages/components/pie-textarea/test/component/pie-textarea.spec.ts index 05a5655abb..ffd15e81d9 100644 --- a/packages/components/pie-textarea/test/component/pie-textarea.spec.ts +++ b/packages/components/pie-textarea/test/component/pie-textarea.spec.ts @@ -3,6 +3,7 @@ import { getFormDataObject, setupFormDataExtraction, } from '@justeattakeaway/pie-webc-testing/src/helpers/form-helpers.ts'; +import { PieFormLabel } from '@justeattakeaway/pie-form-label'; import { PieTextarea, TextareaProps } from '../../src/index.ts'; const componentSelector = '[data-test-id="pie-textarea"]'; @@ -14,6 +15,9 @@ test.describe('PieTextarea - Component tests', () => { test.beforeEach(async ({ mount }) => { const component = await mount(PieTextarea); await component.unmount(); + + const label = await mount(PieFormLabel); + await label.unmount(); }); test('should render successfully', async ({ mount, page }) => { @@ -407,6 +411,180 @@ test.describe('PieTextarea - Component tests', () => { expect(isValid).toBe(true); }); }); + + test.describe('maxLength', () => { + test('should not display a form label when the label is absent but maxLength is provided', async ({ mount }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + maxLength: 10, + } as TextareaProps, + }); + + // Act + const labelNodes = component.locator('pie-form-label'); + + // Assert + await expect(labelNodes).toHaveCount(0); + }); + + test('should not display a maxLength when label is provided but no maxLength', async ({ mount }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + label: 'foo label', + } as TextareaProps, + }); + + // Act + const label = component.getByText('foo label'); + const maxLengthCounterNodes = component.getByTestId('pie-form-label-trailing'); + + // Assert + await expect(label).toBeVisible(); + await expect(maxLengthCounterNodes).toHaveCount(0); + }); + + test('should not display a maxLength when label is provided and maxLength is 0', async ({ mount }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + label: 'foo label', + maxLength: 0, + } as TextareaProps, + }); + + // Act + const label = component.getByText('foo label'); + const maxLengthCounterNodes = component.getByTestId('pie-form-label-trailing'); + + // Assert + await expect(label).toBeVisible(); + await expect(maxLengthCounterNodes).toHaveCount(0); + }); + + test('should display maxLength when label is present and maxLength provided', async ({ mount }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + maxLength: 10, + label: 'foo label', + } as TextareaProps, + }); + + // Act + const maxLengthCounter = component.getByTestId('pie-form-label-trailing'); + + // Assert + await expect(maxLengthCounter).toBeVisible(); + await expect(maxLengthCounter).toHaveText('0/10'); + }); + + test('should update the displayed maxLength as a user types', async ({ mount }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + maxLength: 10, + label: 'foo label', + } as TextareaProps, + }); + + // Act & Assert + const maxLengthCounter = component.getByTestId('pie-form-label-trailing'); + await expect(maxLengthCounter).toBeVisible(); + await expect(maxLengthCounter).toHaveText('0/10'); + + await component.type('12345'); + await expect(maxLengthCounter).toHaveText('5/10'); + }); + + test('should update the displayed maxLength as a user deletes', async ({ mount }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + maxLength: 10, + label: 'foo label', + } as TextareaProps, + }); + + // Act & Assert + const maxLengthCounter = component.getByTestId('pie-form-label-trailing'); + await expect(maxLengthCounter).toBeVisible(); + await expect(maxLengthCounter).toHaveText('0/10'); + + await component.type('12345'); + await expect(maxLengthCounter).toHaveText('5/10'); + + await component.press('Backspace'); + await expect(maxLengthCounter).toHaveText('4/10'); + }); + + test('should not let a user type more than the maxLength', async ({ mount, page }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + maxLength: 5, + label: 'foo label', + } as TextareaProps, + }); + + // Act & Assert + const maxLengthCounter = component.getByTestId('pie-form-label-trailing'); + await expect(maxLengthCounter).toBeVisible(); + await expect(maxLengthCounter).toHaveText('0/5'); + + await component.type('123456'); + await expect(maxLengthCounter).toHaveText('5/5'); + const textareaContent = await page.locator(componentSelector).inputValue(); + expect(textareaContent).toBe('12345'); + }); + + test('should not let a user programmatically set value more than the maxLength', async ({ mount, page }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + maxLength: 5, + label: 'foo label', + value: '123456', + } as TextareaProps, + }); + + // Act & Assert + const maxLengthCounter = component.getByTestId('pie-form-label-trailing'); + await expect(maxLengthCounter).toBeVisible(); + await expect(maxLengthCounter).toHaveText('5/5'); + const textareaContent = await page.locator(componentSelector).inputValue(); + expect(textareaContent).toBe('12345'); + }); + }); + + test.describe('label', () => { + test('should not render a label when the label is absent', async ({ mount }) => { + // Arrange + const component = await mount(PieTextarea, {}); + + // Act + const labelNodes = component.locator('pie-form-label'); + + // Assert + await expect(labelNodes).toHaveCount(0); + }); + + test('should render a label when the label is present', async ({ mount }) => { + // Arrange + const component = await mount(PieTextarea, { + props: { + label: 'foo label', + } as TextareaProps, + }); + + // Act + const label = component.getByText('foo label'); + + // Assert + await expect(label).toBeVisible(); + }); + }); }); test.describe('Form integration', () => { diff --git a/packages/components/pie-textarea/test/visual/pie-textarea.spec.ts b/packages/components/pie-textarea/test/visual/pie-textarea.spec.ts index dd4a9904d2..e594944b8c 100644 --- a/packages/components/pie-textarea/test/visual/pie-textarea.spec.ts +++ b/packages/components/pie-textarea/test/visual/pie-textarea.spec.ts @@ -2,6 +2,8 @@ import { test, expect } from '@sand4rt/experimental-ct-web'; import percySnapshot from '@percy/playwright'; import { percyWidths } from '@justeattakeaway/pie-webc-testing/src/percy/breakpoints.ts'; +import { PieFormLabel } from '@justeattakeaway/pie-form-label'; +import { setRTL } from '@justeattakeaway/pie-webc-testing/src/helpers/set-rtl-direction.ts'; import { PieTextarea } from '../../src/index.ts'; import { sizes } from '../../src/defs.ts'; @@ -14,6 +16,9 @@ test.beforeEach(async ({ mount }, testInfo) => { // It appears to add them to a Playwright cache which we understand is required for the tests to work correctly. const component = await mount(PieTextarea); await component.unmount(); + + const label = await mount(PieFormLabel); + await label.unmount(); }); sizes.forEach((size) => { @@ -142,3 +147,31 @@ test.describe('Resize mode:', () => { }); }); }); + +test.describe('Label and Character count:', () => { + test('Renders the label and character count correctly', async ({ page, mount }) => { + await mount(PieTextarea, { + props: { + label: 'Label', + maxLength: 250, + value: 'This is a test value', + } as PieTextarea, + }); + + await percySnapshot(page, 'Textarea - with label and character count', percyWidths); + }); + + test('RTL - Renders the label and character count correctly', async ({ page, mount }) => { + setRTL(page); + + await mount(PieTextarea, { + props: { + label: 'Label', + maxLength: 250, + value: 'This is a test value', + } as PieTextarea, + }); + + await percySnapshot(page, 'Textarea RTL - with label and character count', percyWidths); + }); +}); diff --git a/yarn.lock b/yarn.lock index 90ed404280..baa7738b92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5808,6 +5808,7 @@ __metadata: dependencies: "@custom-elements-manifest/analyzer": 0.9.0 "@justeattakeaway/pie-components-config": 0.16.0 + "@justeattakeaway/pie-form-label": 0.13.6 "@justeattakeaway/pie-webc-core": 0.24.0 "@types/lodash.throttle": 4.1.9 cem-plugin-module-file-extensions: 0.0.5