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