diff --git a/libs/docs/src/components/common/TextArea.stories.mdx b/libs/docs/src/components/common/TextArea.stories.mdx index c2183492f..991abb787 100644 --- a/libs/docs/src/components/common/TextArea.stories.mdx +++ b/libs/docs/src/components/common/TextArea.stories.mdx @@ -101,6 +101,44 @@ import { GoATextArea } from "@abgov/react-components"; lang="angular" type="() => void" description="Event triggered on each key press" + name="countBy" + required="true" + type="character | word" + lang="react" + description="What the count is of" + /> + + + + + @@ -207,6 +245,139 @@ export const noop = () => { +#### Word count + +export const CharCountTemplate = () => { + const [value, setValue] = useState(""); + return ( + setValue(value)} + /> + ); +}; + +{CharCountTemplate.bind({})} + + + + + + `} + /> + + + + setValue(value)} + /> + ) + `} + lang="tsx" + /> + + + +#### Character count with limit + +export const CharCountWithLimitTemplate = () => { + const [value, setValue] = useState(""); + return ( + setValue(value)} + /> + ); +}; + + + {CharCountWithLimitTemplate.bind({})} + + + + + + + `} + /> + + + + setValue(value)} + /> + ) + `} + lang="tsx" + /> + + + + #### Disabled diff --git a/libs/react-components/src/lib/textarea/textarea.spec.tsx b/libs/react-components/src/lib/textarea/textarea.spec.tsx index de5480a8f..3b2b62750 100644 --- a/libs/react-components/src/lib/textarea/textarea.spec.tsx +++ b/libs/react-components/src/lib/textarea/textarea.spec.tsx @@ -11,8 +11,9 @@ describe("TextArea", () => { rows={10} placeholder="textarea-placeholder" disabled={true} - showCounter={true} - maxCharCount={50} + countBy="word" + showCount={true} + maxCount={50} mt="s" mr="m" mb="l" @@ -27,8 +28,9 @@ describe("TextArea", () => { expect(el.getAttribute("rows")).toBe("10"); expect(el.getAttribute("placeholder")).toBe("textarea-placeholder"); expect(el.getAttribute("disabled")).toBe("true"); - expect(el.getAttribute("showcounter")).toBe("true"); - expect(el.getAttribute("maxcharcount")).toBe("50"); + expect(el.getAttribute("showcount")).toBe("true"); + expect(el.getAttribute("countby")).toBe("word"); + expect(el.getAttribute("maxcount")).toBe("50"); expect(el.getAttribute("mt")).toBe("s"); expect(el.getAttribute("mr")).toBe("m"); expect(el.getAttribute("mb")).toBe("l"); @@ -44,6 +46,7 @@ describe("TextArea", () => { testId="textarea-testid" name="textarea-name" value="textarea-value" + countBy="word" rows={10} placeholder="textarea-placeholder" disabled={true} diff --git a/libs/react-components/src/lib/textarea/textarea.tsx b/libs/react-components/src/lib/textarea/textarea.tsx index dd89404c6..a9b0364f7 100644 --- a/libs/react-components/src/lib/textarea/textarea.tsx +++ b/libs/react-components/src/lib/textarea/textarea.tsx @@ -1,6 +1,9 @@ import { useEffect, useRef } from "react"; import { Margins } from "../../common/styling"; + +type CountBy = "character" | "word"; + interface WCProps extends Margins { ref: React.Ref; name: string; @@ -13,6 +16,9 @@ interface WCProps extends Margins { maxcharcount?: number; width?: string; arialabel?: string; + countby?: CountBy; + showcount?: boolean; + maxcount?: number; } declare global { @@ -24,6 +30,7 @@ declare global { } } + export interface GoATextAreaProps extends Margins { name: string; value: string; @@ -32,11 +39,13 @@ export interface GoATextAreaProps extends Margins { rows?: number; error?: boolean; disabled?: boolean; - showCounter?: boolean; - maxCharCount?: number; width?: string; testId?: string; ariaLabel?: string; + countBy?: CountBy; + showCount?: boolean; + maxCount?: number; + onChange: (name: string, value: string) => void; onKeyPress?: (name: string, value: string, key: string) => void; } @@ -47,8 +56,9 @@ export function GoATextarea({ placeholder, rows, disabled, - showCounter, - maxCharCount, + countBy, + showCount, + maxCount, width, testId, error, @@ -93,8 +103,9 @@ export function GoATextarea({ value={value} rows={rows} disabled={disabled} - showcounter={showCounter} - maxcharcount={maxCharCount} + countby={countBy} + showcount={showCount} + maxcount={maxCount} width={width} error={error} data-testid={testId} diff --git a/libs/web-components/src/common/utils.ts b/libs/web-components/src/common/utils.ts index ae335f9b8..e4f04bcb0 100644 --- a/libs/web-components/src/common/utils.ts +++ b/libs/web-components/src/common/utils.ts @@ -20,7 +20,7 @@ export function validateRequired(componentName: string, props: Record 12 && hour24 - 12 - || hour24; - const meridium = - hour24 === 0 && "AM" - || hour24 >= 12 && "PM" - || "AM"; - const min = - min0 < 10 && `0${min0}` - || min0; - const ordinal - = date % 10 === 1 && date !== 11 && "st" - || date % 10 === 2 && date !== 12 && "nd" - || date % 10 === 3 && date !== 13 && "rd" - || "th"; + const hour = (hour24 === 0 && 12) || (hour24 > 12 && hour24 - 12) || hour24; + const meridium = (hour24 === 0 && "AM") || (hour24 >= 12 && "PM") || "AM"; + const min = (min0 < 10 && `0${min0}`) || min0; + const ordinal = + (date % 10 === 1 && date !== 11 && "st") || + (date % 10 === 2 && date !== 12 && "nd") || + (date % 10 === 3 && date !== 13 && "rd") || + "th"; - return `${month} ${date}${ordinal} ${year}, ${hour}:${min} ${meridium}` + return `${month} ${date}${ordinal} ${year}, ${hour}:${min} ${meridium}`; } export function cssVar(name: string, value: string | number): string { return value ? `${name}: ${value};` : ""; } + +export function pluralize(word: string, count: number) { + if (count === 1) return word; + return `${word}s`; +} diff --git a/libs/web-components/src/components/form-item/FormItem.svelte b/libs/web-components/src/components/form-item/FormItem.svelte index 9281b3cd4..299d62751 100644 --- a/libs/web-components/src/components/form-item/FormItem.svelte +++ b/libs/web-components/src/components/form-item/FormItem.svelte @@ -62,7 +62,7 @@ {#if error}
- + {error}
{/if} @@ -114,6 +114,7 @@ .error-msg { display: inline-flex; + align-items: flex-start; gap: 0.25rem; font-size: var(--goa-font-size-2); color: var(--goa-color-interactive-error); diff --git a/libs/web-components/src/components/text-area/TextArea.spec.ts b/libs/web-components/src/components/text-area/TextArea.spec.ts index 68776aefd..8b46c79e5 100644 --- a/libs/web-components/src/components/text-area/TextArea.spec.ts +++ b/libs/web-components/src/components/text-area/TextArea.spec.ts @@ -1,9 +1,8 @@ import "@testing-library/jest-dom"; import { fireEvent, render, waitFor } from "@testing-library/svelte"; -import GoATextArea from "./TextArea.svelte" +import GoATextArea from "./TextArea.svelte"; describe("GoATextArea", () => { - it("should render", async () => { const result = render(GoATextArea, { name: "name", @@ -23,27 +22,26 @@ describe("GoATextArea", () => { expect(el).toHaveAttribute("rows", "42"); }); - it("handles the change event", async () => { + it.only("handles the change event", async () => { const onChange = jest.fn(); const result = render(GoATextArea, { name: "name", - value: "foo", testid: "test-id", }); - const textarea = result.queryByTestId("test-id"); - textarea.addEventListener("_change", (e: CustomEvent) => { + const el = result.container.querySelector("textarea"); + el.addEventListener("_change", (e: CustomEvent) => { expect(e.detail.name).toBe("name"); - expect(e.detail.value).toBe("bar"); + expect(e.detail.value).toBe("b"); onChange(); }); - await fireEvent.input(textarea, { target: { value: 'bar' } }); + await fireEvent.change(el, { target: { value: "b" } }); await waitFor(() => { expect(onChange).toBeCalledTimes(1); - }) - }) + }); + }); it("handles the keypress event", async () => { const onKeyPress = jest.fn(); @@ -61,71 +59,167 @@ describe("GoATextArea", () => { onKeyPress(); }); - await fireEvent.keyUp(textarea, { target: { value: 'foo' }, key: "o" }); + await fireEvent.keyUp(textarea, { target: { value: "foo" }, key: "o" }); await waitFor(() => { expect(onKeyPress).toBeCalledTimes(1); - }) - }) + }); + }); it("can be disabled", async () => { const onChange = jest.fn(); const result = render(GoATextArea, { name: "name", value: "foo", - testid: "test-id", disabled: "true", }); - const textarea = result.queryByTestId("test-id"); - textarea.addEventListener("_change", onChange); + const el = result.container.querySelector("textarea"); + el.addEventListener("_change", onChange); - await fireEvent.keyUp(textarea, { target: { value: 'bar' } }); - expect(textarea).toHaveAttribute("disabled", ""); + await fireEvent.keyUp(el, { target: { value: "bar" } }); + expect(el).toHaveAttribute("disabled", ""); expect(onChange).not.toBeCalled(); - }) + }); it("indicates an error state", async () => { - const result = render(GoATextArea, { + const { container } = render(GoATextArea, { name: "name", value: "foo", - testid: "test-id", error: "true", }); - const textarea = result.queryByTestId("test-id"); + const textarea = container.querySelector(".root"); expect(textarea).toHaveClass("error"); - }) + }); it("accepts an arialabel property", async () => { - const el = render(GoATextArea, { testid: "input-test", name: "description", arialabel: "Description" }); + const el = render(GoATextArea, { + name: "description", + arialabel: "Description", + }); const root = el.container.querySelector('[aria-label="Description"]'); expect(root).toBeTruthy(); }); it("defaults to the name property if arialabel is not supplied", async () => { - const el = render(GoATextArea, { testid: "input-test", name: "about" }); + const el = render(GoATextArea, { name: "about" }); const root = el.container.querySelector('[aria-label="about"]'); expect(root).toBeTruthy(); }); + describe("Char count", () => { + it("does not show a char count if not enabled", async () => { + const { container } = render(GoATextArea, { name: "test-name" }); + const counterEl = container.querySelector(".counter"); + expect(counterEl).toBeNull(); + }); + + it("shows a character count", async () => { + const { container } = render(GoATextArea, { + name: "test-name", + countby: "character", + showcount: "true", + value: "Jim likes apples", + }); + const counterEl = container.querySelector(".counter"); + expect(counterEl.innerHTML).toContain("16 characters"); + }); + + it("shows a word count", async () => { + const { container } = render(GoATextArea, { + name: "test-name", + countby: "word", + showcount: "true", + value: "Jim likes apples", + }); + const counterEl = container.querySelector(".counter"); + expect(counterEl.innerHTML).toContain("3 words"); + }); + + it("shows the number of characters remaining", async () => { + const { container } = render(GoATextArea, { + name: "test-name", + value: "Jim", + countby: "character", + maxcount: "50", + }); + const counterEl = container.querySelector(".counter"); + expect(counterEl.innerHTML).toContain("47 characters remaining"); + }); + + it("shows the number of characters over by", async () => { + const { container } = render(GoATextArea, { + name: "test-name", + value: "Jim is funny", + countby: "character", + maxcount: "5", + }); + const counterEl = container.querySelector(".counter"); + expect(counterEl.innerHTML).toContain("7 characters too many"); + }); + + it("shows the number of words remaining", async () => { + const { container } = render(GoATextArea, { + name: "test-name", + value: "Jim is funny", + countby: "word", + maxcount: "50", + }); + const counterEl = container.querySelector(".counter"); + expect(counterEl.innerHTML).toContain("47 words remaining"); + }); + + it("shows the number of words over by", async () => { + const { container } = render(GoATextArea, { + name: "test-name", + value: "Jim is super funny", + countby: "word", + maxcount: "3", + }); + const counterEl = container.querySelector(".counter"); + expect(counterEl.innerHTML).toContain("1 word too many"); + }); + + it("shows the count in an error state when the char count exceeds the max value allowed", async () => { + const { container } = render(GoATextArea, { + name: "test-name", + value: "Jim Smith", + countby: "character", + maxcount: "5", + }); + expect(container.querySelector(".counter-error")).not.toBeNull(); + }); + + it("should not show counter when disabled", async () => { + const { container } = render(GoATextArea, { + name: "name", + value: "foo", + countby: "word", + maxcount: "5", + disabled: "true", + }); + + expect(container.querySelector(".counter-error")).toBeNull(); + }); + }); + describe("Margins", () => { it(`should add the margin`, async () => { const baseElement = render(GoATextArea, { - testid: "textarea-test", name: "test", mt: "s", mr: "m", mb: "l", ml: "xl", }); - const textarea = await baseElement.findByTestId("textarea-test"); + const el = await baseElement.findByTestId("root"); - expect(textarea).toBeTruthy(); - expect(textarea).toHaveStyle("margin-top:var(--goa-space-s)"); - expect(textarea).toHaveStyle("margin-right:var(--goa-space-m)"); - expect(textarea).toHaveStyle("margin-bottom:var(--goa-space-l)"); - expect(textarea).toHaveStyle("margin-left:var(--goa-space-xl)"); + expect(el).toBeTruthy(); + expect(el).toHaveStyle("margin-top:var(--goa-space-s)"); + expect(el).toHaveStyle("margin-right:var(--goa-space-m)"); + expect(el).toHaveStyle("margin-bottom:var(--goa-space-l)"); + expect(el).toHaveStyle("margin-left:var(--goa-space-xl)"); }); }); }); diff --git a/libs/web-components/src/components/text-area/TextArea.svelte b/libs/web-components/src/components/text-area/TextArea.svelte index 83e5781d4..a7d7e9cc5 100644 --- a/libs/web-components/src/components/text-area/TextArea.svelte +++ b/libs/web-components/src/components/text-area/TextArea.svelte @@ -2,7 +2,7 @@
0 && count > maxcount)} + class:disabled={isDisabled} style={` ${calculateMargin(mt, mr, mb, ml)}; --width: ${width}; + --char-count-padding: ${showCount || maxcount > 0 ? "2rem" : "0"}; `} >