diff --git a/src/components/counter/ch-counter.scss b/src/components/counter/ch-counter.scss new file mode 100644 index 00000000..946c41d7 --- /dev/null +++ b/src/components/counter/ch-counter.scss @@ -0,0 +1,22 @@ +:host { + /** + * @prop --ch-counter-status__warning-color: + * Specifies the color when is near to limit + * @default currentColor + */ + --ch-counter-status__warning-color: currentColor; + + /** + * @prop --ch-counter-status__error-color: + * Specifies the color when limit was reached + * @default currentColor + */ + --ch-counter-status__error-color: currentColor; +} + +.counter-warning { + color: var(--ch-counter-status__warning-color); +} +.counter-error { + color: var(--ch-counter-status__warning-color); +} diff --git a/src/components/counter/ch-counter.tsx b/src/components/counter/ch-counter.tsx new file mode 100644 index 00000000..a6db2424 --- /dev/null +++ b/src/components/counter/ch-counter.tsx @@ -0,0 +1,64 @@ +import { Component, Element, Host, Prop, State, Watch, h } from "@stencil/core"; + +@Component({ + tag: "ch-counter", + styleUrl: "ch-counter.scss", + shadow: true +}) +export class ChCounter { + @Element() hostElement!: HTMLChCounterElement; + /** + * Represents the value of the input field. + */ + @State() currentLength: number = 0; + + /** + * Represents the maximum length of the input field. + */ + @State() maxLength: number = 0; + + /** + * Represents the initial value of the input field. + * Note: This is required when component is updated by a parent component. + */ + @Prop() readonly initialValue: string = ""; + @Watch("initialValue") + initialValueChanged(newValue: string) { + this.currentLength = newValue.length; + } + + componentWillLoad() { + const chEdit = this.hostElement.querySelector("ch-edit"); + + chEdit?.addEventListener("input", (ev: Event) => { + this.currentLength = (ev.target as HTMLInputElement).value.length; + }); + + this.currentLength = (chEdit?.value ?? this.initialValue).length; + this.maxLength = chEdit?.maxLength ?? 0; + } + + render() { + const remainingChars = this.maxLength - this.currentLength; + const isNearLimit = remainingChars <= 20; + const isAtLimit = remainingChars <= 0; + return ( + + + + {this.maxLength > 0 && ( +
+ {`${this.currentLength} / ${this.maxLength}`} +
+ )} +
+ ); + } +} diff --git a/src/components/counter/tests/ch-counter.e2e.ts b/src/components/counter/tests/ch-counter.e2e.ts new file mode 100644 index 00000000..74fc6da2 --- /dev/null +++ b/src/components/counter/tests/ch-counter.e2e.ts @@ -0,0 +1,158 @@ +import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; +import { testDefaultProperties } from "../../../testing/utils.e2e"; + +testDefaultProperties("ch-counter", { + initialValue: "" +}); + +describe("[ch-counter][basic]", () => { + let page: E2EPage; + let counterRef: E2EElement; + + beforeEach(async () => { + page = await newE2EPage({ + html: ``, + failOnConsoleError: true + }); + + counterRef = await page.find("ch-counter"); + }); + + it("should have Shadow DOM", () => + expect(counterRef.shadowRoot).toBeTruthy()); + + it("should render without counter display when no ch-edit is present", async () => { + const counterContainer = await page.find( + "ch-counter >>> [part='counter-container']" + ); + expect(counterContainer).toBeNull(); + }); + + it("should render without counter display when ch-edit has no maxLength", async () => { + await page.setContent(` + + + + `); + await page.waitForChanges(); + + const counterContainer = await page.find( + "ch-counter >>> [part='counter-container']" + ); + expect(counterContainer).toBeNull(); + }); +}); + +describe("[ch-counter][functionality]", () => { + let page: E2EPage; + let counterRef: E2EElement; + let inputRef: E2EElement; + + beforeEach(async () => { + page = await newE2EPage({ + html: ` + + + + `, + failOnConsoleError: true + }); + + counterRef = await page.find("ch-counter"); + inputRef = await page.find("ch-edit >>> input"); + }); + + it("should display counter when ch-edit has maxLength", async () => { + const counterContainer = await page.find( + "ch-counter >>> [part='counter-container']" + ); + const counterText = await page.find("ch-counter >>> [part='counter-text']"); + + expect(counterContainer).toBeTruthy(); + expect(counterText).toBeTruthy(); + expect(await counterText.textContent).toBe("0 / 100"); + }); + + it("should update counter when typing in ch-edit", async () => { + await inputRef.type("Hello world"); + await page.waitForChanges(); + + const counterText = await page.find("ch-counter >>> [part='counter-text']"); + expect(await counterText.textContent).toBe("11 / 100"); + }); + + it("should show warning state when approaching limit", async () => { + const longText = "a".repeat(81); + await inputRef.type(longText); + await page.waitForChanges(); + + const counterText = await page.find("ch-counter >>> [part='counter-text']"); + expect(await counterText.textContent).toBe("81 / 100"); + expect(await counterText.getAttribute("class")).toContain( + "counter-warning" + ); + }); + + it("should show error state when at limit", async () => { + const longText = "a".repeat(100); + await inputRef.type(longText); + await page.waitForChanges(); + + const counterText = await page.find("ch-counter >>> [part='counter-text']"); + expect(await counterText.textContent).toBe("100 / 100"); + expect(await counterText.getAttribute("class")).toContain("counter-error"); + }); + + it("should show error state when exceeding limit", async () => { + const longText = "a".repeat(105); + await inputRef.type(longText); + await page.waitForChanges(); + + const counterText = await page.find("ch-counter >>> [part='counter-text']"); + expect(await counterText.textContent).toBe("100 / 100"); + expect(await counterText.getAttribute("class")).toContain("counter-error"); + }); + + it("should handle initial value correctly", async () => { + await page.setContent(` + + + + `); + await page.waitForChanges(); + + const counterText = await page.find("ch-counter >>> [part='counter-text']"); + expect(await counterText.textContent).toBe("12 / 100"); + }); + + it("should update counter when initial value changes", async () => { + await counterRef.setProperty("initialValue", "Updated text"); + await page.waitForChanges(); + + const counterText = await page.find("ch-counter >>> [part='counter-text']"); + expect(await counterText.textContent).toBe("12 / 100"); + }); + + it("should work with multiline ch-edit", async () => { + await page.setContent(` + + + + `); + await page.waitForChanges(); + + const textarea = await page.find("ch-edit >>> textarea"); + const counterText = await page.find("ch-counter >>> [part='counter-text']"); + + expect(counterText).toBeTruthy(); + expect(await counterText.textContent).toBe("0 / 50"); + + await textarea.type("Multiline text\nwith newlines"); + await page.waitForChanges(); + + const counterTextUpdated = await page.find( + "ch-counter >>> [part='counter-text']" + ); + expect(await counterTextUpdated.textContent).toBe("28 / 50"); + }); +}); diff --git a/src/showcase/assets/components/counter/counter.showcase.tsx b/src/showcase/assets/components/counter/counter.showcase.tsx new file mode 100644 index 00000000..e566dda1 --- /dev/null +++ b/src/showcase/assets/components/counter/counter.showcase.tsx @@ -0,0 +1,102 @@ +import { h } from "@stencil/core"; + +import { + ShowcaseRenderProperties, + ShowcaseStory, + ShowcaseTemplatePropertyInfo +} from "../types"; +import { renderShowcaseProperties } from "../utils"; + +const state: Partial = {}; + +const showcaseRenderProperties: ShowcaseRenderProperties = + [ + { + caption: "Styles", + properties: [ + { + id: "customVars", + type: "style", + properties: [ + { + id: "--ch-counter-status__warning-color", + caption: "--ch-counter-status__warning-color", + value: "#ffff00", + render: "input", + type: "string" + } + ] + }, + { + id: "customVars", + type: "style", + properties: [ + { + id: "--ch-counter-status__error-color", + caption: "--ch-counter-status__error-color", + value: "#ff0000", + render: "input", + type: "string" + } + ] + } + ] + } + ]; + +const render = () => ( +
+
+

Textarea

+ + + +
+ +
+

input

+ + + +
+
+); + +const showcaseCounterPropertiesInfo: ShowcaseTemplatePropertyInfo[] = + [ + { + name: "class", + fixed: true, + value: "navigation-list navigation-list-secondary", + type: "string" + } + ]; + +export const counterShowcaseStory: ShowcaseStory = { + properties: showcaseRenderProperties, + markupWithoutUIModel: { + react: () => ` + + `, + + stencil: () => ` + + ` + }, + render: render, + state: state +}; diff --git a/src/showcase/assets/components/edit/edit.showcase.tsx b/src/showcase/assets/components/edit/edit.showcase.tsx index d275a10c..88e9c480 100644 --- a/src/showcase/assets/components/edit/edit.showcase.tsx +++ b/src/showcase/assets/components/edit/edit.showcase.tsx @@ -181,7 +181,7 @@ const render = () => ( autocapitalize={state.autocapitalize} autocomplete={state.autocomplete} autoGrow={state.autoGrow} - class="input" + class="input input-error" debounce={state.debounce} disabled={state.disabled} maxLength={state.maxLength} diff --git a/src/showcase/assets/components/pages.ts b/src/showcase/assets/components/pages.ts index 659d4298..e61c5654 100644 --- a/src/showcase/assets/components/pages.ts +++ b/src/showcase/assets/components/pages.ts @@ -115,6 +115,11 @@ export const showcasePages: NavigationListModel = [ link: { url: "#switch" }, caption: "Switch", metadata: EXPERIMENTAL + }, + { + link: { url: "#counter" }, + caption: "Counter", + metadata: EXPERIMENTAL } ] }, diff --git a/src/showcase/assets/components/showcase-stories.ts b/src/showcase/assets/components/showcase-stories.ts index 6fff43da..427af3d7 100644 --- a/src/showcase/assets/components/showcase-stories.ts +++ b/src/showcase/assets/components/showcase-stories.ts @@ -11,6 +11,7 @@ import { codeShowcaseStory } from "./code/code.showcase"; import { colorFieldShowcaseStory } from "./color-field/color-field.showcase"; import { colorPickerShowcaseStory } from "./color-picker/color-picker.showcase"; import { comboBoxShowcaseStory } from "./combo-box/combo-box.showcase"; +import { counterShowcaseStory } from "./counter/counter.showcase"; import { dialogShowcaseStory } from "./dialog/dialog.showcase"; import { editShowcaseStory } from "./edit/edit.showcase"; import { flexibleLayoutShowcaseStory } from "./flexible-layout/flexible-layout.showcase"; @@ -53,6 +54,7 @@ export const showcaseStories = { "color-field": colorFieldShowcaseStory, "color-picker": colorPickerShowcaseStory, "combo-box": comboBoxShowcaseStory, + counter: counterShowcaseStory, dialog: dialogShowcaseStory, edit: editShowcaseStory, image: imageShowcaseStory, diff --git a/src/showcase/assets/components/types.ts b/src/showcase/assets/components/types.ts index 0d088662..a818b393 100644 --- a/src/showcase/assets/components/types.ts +++ b/src/showcase/assets/components/types.ts @@ -169,6 +169,7 @@ export type ChameleonStories = { "color-field": ShowcaseStory; "color-picker": ShowcaseStory; "combo-box": ShowcaseStory; + counter: ShowcaseStory; dialog: ShowcaseStory; edit: ShowcaseStory; image: ShowcaseStory;