From 2019077301b19305d63f3d8838d7e93496787801 Mon Sep 17 00:00:00 2001 From: Chris Olsen Date: Thu, 7 Sep 2023 11:08:16 -0600 Subject: [PATCH 1/4] chore: formatting --- .../src/components/form-item/FormItem.spec.ts | 27 +- .../src/components/input/Input.spec.ts | 243 +++++++++++------- .../src/components/input/Input.svelte | 28 +- .../src/components/popover/popover.spec.ts | 29 ++- 4 files changed, 199 insertions(+), 128 deletions(-) diff --git a/libs/web-components/src/components/form-item/FormItem.spec.ts b/libs/web-components/src/components/form-item/FormItem.spec.ts index 283eabd05..b7835a9ed 100644 --- a/libs/web-components/src/components/form-item/FormItem.spec.ts +++ b/libs/web-components/src/components/form-item/FormItem.spec.ts @@ -1,14 +1,13 @@ import "@testing-library/jest-dom"; import { render, cleanup } from "@testing-library/svelte"; -import GoAFormItem from "./FormItem.svelte" +import GoAFormItem from "./FormItem.svelte"; afterEach(cleanup); describe("GoA FormItem", () => { - it("should render with no params", async () => { - const result = render(GoAFormItem); - const el = result.container.querySelector(".goa-form-item"); + const result = render(GoAFormItem, { testid: "foo" }); + const el = result.queryByTestId("foo"); const label = el.querySelector(".label"); expect(label).toBeFalsy(); @@ -25,9 +24,10 @@ describe("GoA FormItem", () => { it("should not show any requirement text for a field when requirement is not set", async () => { const result = render(GoAFormItem, { - label: "Credit Card Number" + testid: "foo", + label: "Credit Card Number", }); - const el = result.container.querySelector(".goa-form-item"); + const el = result.queryByTestId("foo"); const label = el.querySelector(".label"); expect(label).toBeTruthy(); @@ -44,10 +44,11 @@ describe("GoA FormItem", () => { it("should show optional text for a field when requirement set to optional", async () => { const result = render(GoAFormItem, { + testid: "foo", label: "Credit Card Number", - requirement: "optional" + requirement: "optional", }); - const el = result.container.querySelector(".goa-form-item"); + const el = result.queryByTestId("foo"); const label = el.querySelector(".label"); expect(label).toBeTruthy(); @@ -62,13 +63,13 @@ describe("GoA FormItem", () => { expect(errMsg).toBeFalsy(); }); - it("should show required text for a field when requirement set to required", async () => { const result = render(GoAFormItem, { + testid: "foo", label: "Credit Card Number", - requirement: "required" + requirement: "required", }); - const el = result.container.querySelector(".goa-form-item"); + const el = result.queryByTestId("foo"); const label = el.querySelector(".label"); expect(label).toBeTruthy(); @@ -89,7 +90,7 @@ describe("GoA FormItem", () => { helptext: "the helptext", requirement: "optional", error: "the error", - id: "labelId" + id: "labelId", }); const label = document.querySelector(".label"); @@ -127,7 +128,7 @@ describe("GoA FormItem", () => { render(GoAFormItem, { label: "Credit Card Number", - requirement: "requireddddddddd" + requirement: "requireddddddddd", }); const label = document.querySelector(".label"); diff --git a/libs/web-components/src/components/input/Input.spec.ts b/libs/web-components/src/components/input/Input.spec.ts index 765c70666..beea716e3 100644 --- a/libs/web-components/src/components/input/Input.spec.ts +++ b/libs/web-components/src/components/input/Input.spec.ts @@ -1,12 +1,11 @@ -import '@testing-library/jest-dom'; -import { render, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; -import GoAInput from './Input.svelte' -import GoAInputWrapper from './Input.test.svelte' +import "@testing-library/jest-dom"; +import { render, fireEvent, waitFor, cleanup } from "@testing-library/svelte"; +import GoAInput from "./Input.svelte"; +import GoAInputWrapper from "./Input.test.svelte"; afterEach(cleanup); -describe('GoAInput Component', () => { - +describe("GoAInput Component", () => { it("should render", async () => { const el = render(GoAInput, { testid: "input-test", id: "test" }); const input = await el.findByTestId('input-test'); @@ -17,68 +16,93 @@ describe('GoAInput Component', () => { describe("Properties", () => { it("allows for setting of the value", async () => { const el = render(GoAInput, { testid: "input-test", value: "foobar" }); - const input = await el.findByTestId('input-test'); + const input = await el.findByTestId("input-test"); expect((input as HTMLInputElement).value).toBe("foobar"); }); it("allows for setting of the name", async () => { const el = render(GoAInput, { testid: "input-test", name: "test-id" }); - const input = await el.findByTestId('input-test'); + const input = await el.findByTestId("input-test"); expect(input.getAttribute("name")).toBe("test-id"); }); - ["text", "number", "password", "email", "date", "datetime-local", "month", "search", "tel", "time", "url", "week"].forEach((type: string) => { + [ + "text", + "number", + "password", + "email", + "date", + "datetime-local", + "month", + "search", + "tel", + "time", + "url", + "week", + ].forEach((type: string) => { it(`renders the ${type} type`, async () => { const el = render(GoAInput, { testid: "input-test", type }); - const input = await el.findByTestId('input-test'); + const input = await el.findByTestId("input-test"); expect(input.getAttribute("type")).toBe(type); - }) + }); }); it("allows for placeholder text", async () => { - const el = render(GoAInput, { testid: "input-test", placeholder: "test-id" }); - const input = await el.findByTestId('input-test'); + const el = render(GoAInput, { + testid: "input-test", + placeholder: "test-id", + }); + const input = await el.findByTestId("input-test"); expect(input.getAttribute("placeholder")).toBe("test-id"); }); it("allows for a leading icon", async () => { - const el = render(GoAInput, { testid: "input-test", leadingicon: "finger-print" }); - const icon = await el.findByTestId('leading-icon'); + const el = render(GoAInput, { + testid: "input-test", + leadingicon: "finger-print", + }); + const icon = await el.findByTestId("leading-icon"); expect(icon).toBeTruthy(); }); it("allows for a trailing icon", async () => { - const el = render(GoAInput, { testid: "input-test", trailingicon: "finger-print" }); - const icon = await el.findByTestId('trailing-icon'); + const el = render(GoAInput, { + testid: "input-test", + trailingicon: "finger-print", + }); + const icon = await el.findByTestId("trailing-icon"); expect(icon).toBeTruthy(); }); it("allows for a variant", async () => { const el = render(GoAInput, { testid: "input-test", variant: "bare" }); - const root = el.container.querySelector('.variant--bare'); + const root = el.container.querySelector(".variant--bare"); expect(root).toBeTruthy(); }); it("can be disabled", async () => { const el = render(GoAInput, { testid: "input-test", disabled: "true" }); - const root = el.container.querySelector('.goa-input--disabled'); + const root = el.container.querySelector(".input--disabled"); expect(root).toBeTruthy(); }); it("allows the input to be marked as readonly", async () => { const el = render(GoAInput, { testid: "input-test", readonly: "true" }); - const root = el.container.querySelector('input[readonly]'); + const root = el.container.querySelector("input[readonly]"); expect(root).toBeTruthy(); }); it("allows the input to be set to an error state", async () => { const el = render(GoAInput, { testid: "input-test", error: "true" }); - const root = el.container.querySelector('.error'); + const root = el.container.querySelector(".error"); expect(root).toBeTruthy(); }); it("accepts an arialabel property", async () => { - const el = render(GoAInput, { testid: "input-test", arialabel: "First Name" }); + const el = render(GoAInput, { + testid: "input-test", + arialabel: "First Name", + }); const root = el.container.querySelector('[aria-label="First Name"]'); expect(root).toBeTruthy(); }); @@ -96,16 +120,22 @@ describe('GoAInput Component', () => { }); it("has an autocapitalize prop", async () => { - const el = render(GoAInput, { testid: "input-test", autocapitalize: "on" }); - const root = el.container.querySelector('[autocapitalize=on]'); + const el = render(GoAInput, { + testid: "input-test", + autocapitalize: "on", + }); + const root = el.container.querySelector("[autocapitalize=on]"); expect(root).toBeTruthy(); }); - }) - + }); it("allows for the trailing icon click event handling", async () => { - const el = render(GoAInput, { testid: "input-test", trailingicon: "finger-print", handletrailingiconclick: "true" }); - const icon = await el.findByTestId('trailing-icon-button'); + const el = render(GoAInput, { + testid: "input-test", + trailingicon: "finger-print", + handletrailingiconclick: "true", + }); + const icon = await el.findByTestId("trailing-icon-button"); const click = jest.fn(); icon.addEventListener("_trailingIconClick", click); @@ -115,52 +145,63 @@ describe('GoAInput Component', () => { it("allows the input to be focused", async () => { const el = render(GoAInput, { testid: "input-test", focused: "true" }); - const input = el.container.querySelector('input'); + const input = el.container.querySelector("input"); await waitFor(() => { expect(input).toHaveFocus(); - }) + }); }); it("handles keyup event", async () => { - const { findByTestId } = render(GoAInput, { name: 'test-name', testid: "input-test" }); - const input = await findByTestId('input-test'); + const { findByTestId } = render(GoAInput, { + name: "test-name", + testid: "input-test", + }); + const input = await findByTestId("input-test"); const change = jest.fn(); - input.addEventListener('_change', (e: CustomEvent) => { + input.addEventListener("_change", (e: CustomEvent) => { expect(e.detail.name).toBe("test-name"); expect(e.detail.value).toBe("foobar"); change(); }); - await fireEvent.keyUp(input, { target: { value: 'foobar' } }); + await fireEvent.keyUp(input, { target: { value: "foobar" } }); await waitFor(() => { expect(change).toBeCalledTimes(1); - }) + }); }); // The change event is what is fired when selecting a date from the calender supplied // by the `date` input type. Both the change and keyup event handling is required. it("handles the change event", async () => { - const { findByTestId } = render(GoAInput, { name: 'test-name', testid: "input-test", type: "date" }); - const input = await findByTestId('input-test'); + const { findByTestId } = render(GoAInput, { + name: "test-name", + testid: "input-test", + type: "date", + }); + const input = await findByTestId("input-test"); const change = jest.fn(); - input.addEventListener('_change', () => { + input.addEventListener("_change", () => { change(); }); - await fireEvent.change(input) + await fireEvent.change(input); await waitFor(() => { expect(change).toBeCalledTimes(1); - }) - }) + }); + }); it("handles trailing icon click", async () => { - const { findByTestId } = render(GoAInput, { testid: "input-test", handletrailingiconclick: "true", trailingicon: "finger-print" }); + const { findByTestId } = render(GoAInput, { + testid: "input-test", + handletrailingiconclick: "true", + trailingicon: "finger-print", + }); const onClick = jest.fn(); - const iconButton = await findByTestId('trailing-icon-button'); + const iconButton = await findByTestId("trailing-icon-button"); - iconButton.addEventListener('_trailingIconClick', onClick) + iconButton.addEventListener("_trailingIconClick", onClick); await fireEvent.click(iconButton); expect(onClick).toBeCalledTimes(1); @@ -168,64 +209,77 @@ describe('GoAInput Component', () => { describe("type=number", () => { it("allows for a numeric props", async () => { - const el = render(GoAInput, { type: "number", min: "0", max: "10", step: 2 }); - const root = el.container.querySelector('input'); + const el = render(GoAInput, { + type: "number", + min: "0", + max: "10", + step: 2, + }); + const root = el.container.querySelector("input"); expect(root).toBeTruthy(); - expect(root).toHaveAttribute("min", "0") - expect(root).toHaveAttribute("max", "10") - expect(root).toHaveAttribute("step", "2") + expect(root).toHaveAttribute("min", "0"); + expect(root).toHaveAttribute("max", "10"); + expect(root).toHaveAttribute("step", "2"); }); - }) + }); describe("maxlength", () => { it("allows for a maxlength prop", async () => { const el = render(GoAInput, { maxlength: 10 }); - const root = el.container.querySelector('input'); + const root = el.container.querySelector("input"); expect(root).toBeTruthy(); - expect(root).toHaveAttribute("maxlength", "10") + expect(root).toHaveAttribute("maxlength", "10"); }); }); describe("type=date", () => { it("allows for a date type", async () => { const el = render(GoAInput, { type: "date" }); - const input = el.container.querySelector('input'); + const input = el.container.querySelector("input"); expect(input).toBeTruthy(); - expect(input.getAttribute("min")).toBe("") - expect(input.getAttribute("max")).toBe("") - expect(input.getAttribute("step")).toBe("1") + expect(input.getAttribute("min")).toBe(""); + expect(input.getAttribute("max")).toBe(""); + expect(input.getAttribute("step")).toBe("1"); }); - }) + }); describe("Search type", () => { it("clears the input when the search x icon is clicked", async () => { - const { findByTestId } = render(GoAInput, { name: 'test-name', testid: "input-test", type: "search" }); - const input = await findByTestId('input-test'); + const { findByTestId } = render(GoAInput, { + name: "test-name", + testid: "input-test", + type: "search", + }); + const input = await findByTestId("input-test"); const search = jest.fn(); - input.addEventListener('_change', () => { + input.addEventListener("_change", () => { search(); }); - await fireEvent(input, new Event("search")) + await fireEvent(input, new Event("search")); await waitFor(() => { expect(search).toBeCalledTimes(1); - }) - }) + }); + }); it("does fire the search event if it is not a search input type", async () => { - const { findByTestId } = render(GoAInput, { name: 'test-name', testid: "input-test", type: "text" }); - const input = await findByTestId('input-test'); + const { findByTestId } = render(GoAInput, { + name: "test-name", + testid: "input-test", + type: "text", + }); + const input = await findByTestId("input-test"); const search = jest.fn(); - input.addEventListener('_change', () => { + input.addEventListener("_change", () => { search(); }); - await fireEvent(input, new Event("search")) + await fireEvent(input, new Event("search")); expect(search).toBeCalledTimes(0); - }) - }) + }); + }); describe("Prefix and suffix text", () => { it("does not show prefix or suffix text", async () => { @@ -239,19 +293,24 @@ describe('GoAInput Component', () => { expect(container.querySelector(".prefix").innerHTML).toContain("$"); await waitFor(() => { expect(console.warn["mock"].calls.length).toBeGreaterThan(0); - }) + }); mock.mockRestore(); }); it("shows suffix text and also a warning message in console", async () => { const mock = jest.spyOn(console, "warn").mockImplementation(); - const { container } = render(GoAInput, { type: "text", suffix: "per item" }); - expect(container.querySelector(".suffix").innerHTML).toContain("per item"); + const { container } = render(GoAInput, { + type: "text", + suffix: "per item", + }); + expect(container.querySelector(".suffix").innerHTML).toContain( + "per item", + ); await waitFor(() => { expect(console.warn["mock"].calls.length).toBeGreaterThan(0); - }) + }); mock.mockRestore(); }); - }) + }); describe("Margins", () => { it(`should add the margin`, async () => { @@ -283,16 +342,20 @@ describe('GoAInput Component', () => { const content = "$"; const el = render(GoAInputWrapper, { leadingContent: content }); expect(el.container.innerHTML).toContain(content); - expect(el.container.querySelector("[slot=leadingContent]").innerHTML).toContain(content); + expect( + el.container.querySelector("[slot=leadingContent]").innerHTML, + ).toContain(content); }); it("should have a slot for the trailing content", async () => { const content = "items"; const el = render(GoAInputWrapper, { trailingContent: content }); expect(el.container.innerHTML).toContain(content); - expect(el.container.querySelector("[slot=trailingContent]").innerHTML).toContain(content); + expect( + el.container.querySelector("[slot=trailingContent]").innerHTML, + ).toContain(content); }); - }) + }); it("should delay the _change event when debounce is set", async () => { const fn = jest.fn(); @@ -300,17 +363,23 @@ describe('GoAInput Component', () => { const input = el.getByTestId("input-test"); expect(input).toBeTruthy(); - input.addEventListener('_change', (e: CustomEvent) => { + input.addEventListener("_change", (e: CustomEvent) => { fn(); }); - await fireEvent.keyUp(input, { target: { value: 'foobar' } }); - await waitFor(() => { - expect(fn).not.toBeCalled(); - }, { timeout: 500 }) - - await waitFor(() => { - expect(fn).toBeCalled(); - }, { timeout: 2000}) - }) + await fireEvent.keyUp(input, { target: { value: "foobar" } }); + await waitFor( + () => { + expect(fn).not.toBeCalled(); + }, + { timeout: 500 }, + ); + + await waitFor( + () => { + expect(fn).toBeCalled(); + }, + { timeout: 2000 }, + ); + }); }); diff --git a/libs/web-components/src/components/input/Input.svelte b/libs/web-components/src/components/input/Input.svelte index 2f2621d4b..a6f246074 100644 --- a/libs/web-components/src/components/input/Input.svelte +++ b/libs/web-components/src/components/input/Input.svelte @@ -111,6 +111,7 @@ composed: true, detail: { name, value: input.value } })); } + function onBlur(e: Event){ const input = e.target as HTMLInputElement; input.dispatchEvent(new CustomEvent("_blur", { @@ -140,27 +141,21 @@ } }); +
{#if prefix} @@ -168,6 +163,7 @@ {prefix}
{/if} +
@@ -182,11 +178,9 @@ { - const { container } = render(PopoverWrapper, {content: "This is content", targetTrigger: "Clik Action"}) - expect(container.querySelector("[slot=content]").innerHTML).toContain("This is content"); - expect(container.querySelector("[slot=target]").innerHTML).toContain("Clik Action"); +it("should render", async () => { + const { container } = render(PopoverWrapper, { + content: "This is content", + targetTrigger: "Clik Action", + }); + expect(container.querySelector("[slot=content]").innerHTML).toContain( + "This is content", + ); + expect(container.querySelector("[slot=target]").innerHTML).toContain( + "Clik Action", + ); }); -it('should open content when target is clicked', async () => { - const result = render(Popover) +it("should open content when target is clicked", async () => { + const result = render(Popover); const target = result.queryByTestId("popover-target"); expect(result.queryByTestId("popover-content")).toBeNull(); await fireEvent.click(target); expect(result.queryByTestId("popover-content")).toBeTruthy(); }); -it('should close content when clicked outside the content container', async () => { - const result = render(Popover) +it("should close content when clicked outside the content container", async () => { + const result = render(Popover); const target = result.queryByTestId("popover-target"); await fireEvent.click(target); const background = result.queryByTestId("popover-background"); From ec733dc35617e3d058a64665bbd9dcfe676013af Mon Sep 17 00:00:00 2001 From: Chris Olsen Date: Thu, 7 Sep 2023 11:09:24 -0600 Subject: [PATCH 2/4] fix: remove unwanted padding on top of goa-form-item --- libs/web-components/src/components/form-item/FormItem.svelte | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/web-components/src/components/form-item/FormItem.svelte b/libs/web-components/src/components/form-item/FormItem.svelte index d26626fee..341aa5b70 100644 --- a/libs/web-components/src/components/form-item/FormItem.svelte +++ b/libs/web-components/src/components/form-item/FormItem.svelte @@ -41,7 +41,6 @@
{#if label}
@@ -81,7 +80,7 @@ font-weight: var(--goa-font-weight-bold); color: var(--goa-color-text-default); font-size: var(--goa-font-size-4); - padding: 0.5rem 0; + padding-bottom: 0.5rem; } .label em { From 03480eab5120f523eaa116ab2e2dca166ecbc1ad Mon Sep 17 00:00:00 2001 From: Chris Olsen Date: Thu, 7 Sep 2023 11:41:15 -0600 Subject: [PATCH 3/4] chore: remove the Enter key handling from Popover This was preventing the expected behaviour when a dropdown (popover) exists within a popover --- .../src/components/popover/Popover.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/web-components/src/components/popover/Popover.svelte b/libs/web-components/src/components/popover/Popover.svelte index 9fb4b2757..6590d85bb 100644 --- a/libs/web-components/src/components/popover/Popover.svelte +++ b/libs/web-components/src/components/popover/Popover.svelte @@ -18,9 +18,12 @@ export let padded: string = "true"; // provides control to where the popover content is positioned export let position: "above" | "below" | "auto" = "auto"; - + // ajust positioning when popover component is contained within a relative positioned parent export let relative: string = "false"; + export let autoclose: string = "false" + + // margins export let mt: Spacing = null; export let mr: Spacing = null; @@ -53,14 +56,15 @@ let _popoverEl: HTMLElement; let _focusTrapEl: HTMLElement; let _initFocusedEl: HTMLElement; + let _sectionHeight: number; - let _sectionHeight; // Reactive $: _padded = toBoolean(padded); $: _open = toBoolean(open); $: _disabled = toBoolean(disabled); $: _relative = toBoolean(relative); + $: _autoClose = toBoolean(autoclose); $: (async () => _open && await setPopoverPosition())() $: (async () => _sectionHeight && await setPopoverPosition())() @@ -119,10 +123,6 @@ // targetEl eventing binding from "hearing" the events function onFocusTrapEvent(e: KeyboardEvent) { switch (e.key) { - case "Enter": - // setTimeout allows the url to change before closing - setTimeout(closePopover, 1) - break; case "Escape": closePopover(); break; From 9e90df231df8ab1b33db0edc334fb403a3dc15bd Mon Sep 17 00:00:00 2001 From: Chris Olsen Date: Thu, 7 Sep 2023 11:41:47 -0600 Subject: [PATCH 4/4] feat(#1264): calendar and datepicker components --- .../input-component.component.html | 5 + .../input-component.component.ts | 2 + .../components/common/Calendar.stories.mdx | 186 +++++++ .../components/common/DatePicker.stories.mdx | 200 +++++++ libs/react-components/src/index.ts | 3 + .../src/lib/calendar/calendar.spec.tsx | 25 + .../src/lib/calendar/calendar.tsx | 67 +++ .../src/lib/date-picker/date-picker.spec.tsx | 57 ++ .../src/lib/date-picker/date-picker.tsx | 67 +++ .../playground/src/pg-datepicker.svelte | 35 ++ .../playground/src/pg-dropdown.svelte | 26 +- .../calendar/Calendar.html-data.json | 51 ++ .../src/components/calendar/Calendar.svelte | 433 ++++++++++++++++ .../src/components/calendar/calendar.spec.ts | 330 ++++++++++++ .../src/components/calendar/doc.md | 10 + .../date-picker/DatePicker.html-data.json | 57 ++ .../components/date-picker/DatePicker.svelte | 124 +++++ .../date-picker/date-picker.spec.ts | 155 ++++++ .../src/components/date-picker/doc.md | 11 + .../src/components/dropdown/Dropdown.spec.ts | 489 +++++++++++------- .../src/components/dropdown/Dropdown.svelte | 59 ++- libs/web-components/src/index.ts | 42 +- 22 files changed, 2181 insertions(+), 253 deletions(-) create mode 100644 libs/docs/src/components/common/Calendar.stories.mdx create mode 100644 libs/docs/src/components/common/DatePicker.stories.mdx create mode 100644 libs/react-components/src/lib/calendar/calendar.spec.tsx create mode 100644 libs/react-components/src/lib/calendar/calendar.tsx create mode 100644 libs/react-components/src/lib/date-picker/date-picker.spec.tsx create mode 100644 libs/react-components/src/lib/date-picker/date-picker.tsx create mode 100644 libs/web-components/playground/src/pg-datepicker.svelte create mode 100644 libs/web-components/src/components/calendar/Calendar.html-data.json create mode 100644 libs/web-components/src/components/calendar/Calendar.svelte create mode 100644 libs/web-components/src/components/calendar/calendar.spec.ts create mode 100644 libs/web-components/src/components/calendar/doc.md create mode 100644 libs/web-components/src/components/date-picker/DatePicker.html-data.json create mode 100644 libs/web-components/src/components/date-picker/DatePicker.svelte create mode 100644 libs/web-components/src/components/date-picker/date-picker.spec.ts create mode 100644 libs/web-components/src/components/date-picker/doc.md diff --git a/apps/angular-demo/src/app/input-component/input-component.component.html b/apps/angular-demo/src/app/input-component/input-component.component.html index ef888be43..0471c640a 100644 --- a/apps/angular-demo/src/app/input-component/input-component.component.html +++ b/apps/angular-demo/src/app/input-component/input-component.component.html @@ -1,5 +1,10 @@

Input

+ +

Basic

+ +# Calendar + +### Calendar allows for date selection + +[Figma design component](https://www.figma.com/file/dcR7GrX9kBvnLxA71m2ia8/Component---Date-picker?type=design&node-id=2295%3A53257&mode=design&t=db97sQqDODSxf6bj-1) + +--- + +#### Properties + + + + + + + + + +
+ Additional Properties + + + + + + +
+ +#### Basic + + + {}} /> + + + + + `} + /> + + + {}} />`} + /> + + + +#### Date + + + {}} value={new Date()} /> + + + + + + + `} + /> + + + {}} value={new Date()} /> + `} + /> + + + +#### Min/Max + + + {}} + value={new Date(2023, 3, 1)} + min={new Date(2023, 2, 1)} + max={new Date(2023, 4, 1)} + /> + + + + + + + + `} + /> + + + {}} + value={new Date(2023, 3, 1)} + min={new Date(2023, 2, 1)} + max={new Date(2023, 4, 1)} + /> + `} + /> + + + +--- + + diff --git a/libs/docs/src/components/common/DatePicker.stories.mdx b/libs/docs/src/components/common/DatePicker.stories.mdx new file mode 100644 index 000000000..a3b63c803 --- /dev/null +++ b/libs/docs/src/components/common/DatePicker.stories.mdx @@ -0,0 +1,200 @@ +import { Meta, Story } from "@storybook/addon-docs"; +import { + Props, + Prop, + Tabs, + Tab, + CodeSnippet, + SupportInfo, +} from "@abgov/shared/storybook-common"; +import { GoADatePicker } from "@abgov/react-components"; + + + +# Date Picker + +### The Date picker lets users select a date. This enables a date to be selected without the need to manually type it in a field, thereby reducing input errors and improving data accuracy. + +[Figma design component](https://www.figma.com/file/dcR7GrX9kBvnLxA71m2ia8/Component---Date-picker?type=design&node-id=2295-53257&mode=design&t=mx1noZu1YH4gtdth-0) + +--- + +#### Properties + + + + + + + + + +
+ Additional Properties + + + + + + +
+ +#### Basic + + + {}} /> + + + + + + + `} + /> + + + {}} /> + `} + /> + + + +#### Date + + + {}} /> + + + + + + + `} + /> + + + {}} value={new Date()} /> + `} + /> + + + +#### Min/Max + + + {}} + value={new Date(2023, 3, 1)} + min={new Date(2023, 2, 1)} + max={new Date(2023, 4, 1)} + onChange={() => {}} + /> + + + + + + + + `} + /> + + + {}} + value={new Date(2023, 3, 1)} + min={new Date(2023, 2, 1)} + max={new Date(2023, 4, 1)} + /> + `} + /> + + + +--- + + diff --git a/libs/react-components/src/index.ts b/libs/react-components/src/index.ts index e7797139c..a87059c0a 100644 --- a/libs/react-components/src/index.ts +++ b/libs/react-components/src/index.ts @@ -1,5 +1,8 @@ import "@abgov/web-components"; +export * from "./lib/date-picker/date-picker"; +export * from "./lib/calendar/calendar"; + export * from "./lib/side-menu-group/side-menu-group"; export * from "./lib/side-menu-heading/side-menu-heading"; export * from "./lib/side-menu/side-menu"; diff --git a/libs/react-components/src/lib/calendar/calendar.spec.tsx b/libs/react-components/src/lib/calendar/calendar.spec.tsx new file mode 100644 index 000000000..d705a91e6 --- /dev/null +++ b/libs/react-components/src/lib/calendar/calendar.spec.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render } from "@testing-library/react"; + +import Calendar from "./calendar"; + +describe("Calendar", () => { + it("should render successfully", () => { + const noop = () => {}; + const { baseElement, container } = render(); + expect(baseElement).toBeTruthy(); + + const calendar = container.querySelector("goa-calendar"); + expect(calendar).toBeTruthy(); + }); + + it("handle the event", () => { + const onChange = jest.fn(); + const name = "birthdate"; + const { container } = render(); + const calendar = container.querySelector("goa-calendar"); + + const detail = { type: "date", value: new Date(), name }; + fireEvent(calendar, new CustomEvent("_change", { detail })); + expect(onChange).toBeCalled(); + }); +}); diff --git a/libs/react-components/src/lib/calendar/calendar.tsx b/libs/react-components/src/lib/calendar/calendar.tsx new file mode 100644 index 000000000..12c97cceb --- /dev/null +++ b/libs/react-components/src/lib/calendar/calendar.tsx @@ -0,0 +1,67 @@ +import React, { FC, useEffect, useRef } from "react"; +import { Margins } from "../../common/styling"; + +interface Props extends Margins { + name?: string; + value?: Date; + min?: Date; + max?: Date; + onChange: (name: string, value: Date) => void; +} + +interface WCProps extends Margins { + ref: React.RefObject; + name?: string; + value?: string; + min?: string; + max?: string; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface IntrinsicElements { + "goa-calendar": WCProps & React.HTMLAttributes; + } + } +} + +export const GoACalendar: FC = ({ + name, + value, + min, + max, + mt, + mr, + mb, + ml, + onChange, +}: Props) => { + const ref = useRef(null); + useEffect(() => { + if (!ref.current) { + return; + } + const current = ref.current; + current.addEventListener("_change", (e: Event) => { + onChange(name || "", (e as CustomEvent).detail.value); + }); + }); + + return ( + + ); +}; + +export default GoACalendar; diff --git a/libs/react-components/src/lib/date-picker/date-picker.spec.tsx b/libs/react-components/src/lib/date-picker/date-picker.spec.tsx new file mode 100644 index 000000000..e8db9d5f5 --- /dev/null +++ b/libs/react-components/src/lib/date-picker/date-picker.spec.tsx @@ -0,0 +1,57 @@ +import { render, waitFor } from "@testing-library/react"; +import { addMonths } from "date-fns"; + +import DatePicker from "./date-picker"; + +describe("DatePicker", () => { + it("should render successfully", () => { + const noop = () => {}; + const value = new Date(); + const min = addMonths(value, -1); + const max = addMonths(value, 1); + + const { baseElement } = render( + , + ); + + expect(baseElement).toBeTruthy(); + + const el = baseElement.querySelector("goa-date-picker"); + expect(el).toBeTruthy(); + expect(el.getAttribute("name")).toBe("foo"); + expect(el.getAttribute("value")).toBe(value.toISOString()); + expect(el.getAttribute("min")).toBe(min.toISOString()); + expect(el.getAttribute("max")).toBe(max.toISOString()); + }); + + it("should handle event", async () => { + const name = "foo"; + const value = new Date(); + + const onChange = jest.fn(); + const { baseElement } = render( + , + ); + + const el = baseElement.querySelector("goa-date-picker"); + el.dispatchEvent( + new CustomEvent("_change", { + composed: true, + bubbles: true, + detail: { + type: "date", + name, + value, + }, + }), + ); + + expect(onChange).toBeCalledWith(name, value); + }); +}); diff --git a/libs/react-components/src/lib/date-picker/date-picker.tsx b/libs/react-components/src/lib/date-picker/date-picker.tsx new file mode 100644 index 000000000..20cf1be43 --- /dev/null +++ b/libs/react-components/src/lib/date-picker/date-picker.tsx @@ -0,0 +1,67 @@ +import React, { FC, useEffect, useRef } from "react"; +import { Margins } from "../../common/styling"; + +interface Props extends Margins { + name?: string; + value?: Date; + min?: Date; + max?: Date; + onChange: (name: string, value: Date) => void; +} + +interface WCProps extends Margins { + ref: React.RefObject; + name?: string; + value?: string; + min?: string; + max?: string; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface IntrinsicElements { + "goa-date-picker": WCProps & React.HTMLAttributes; + } + } +} + +export const GoADatePicker: FC = ({ + name, + value, + min, + max, + mt, + mr, + mb, + ml, + onChange, +}: Props) => { + const ref = useRef(null); + useEffect(() => { + if (!ref.current) { + return; + } + const current = ref.current; + current.addEventListener("_change", (e: Event) => { + onChange(name || "", (e as CustomEvent).detail.value); + }); + }); + + return ( + + ); +}; + +export default GoADatePicker; diff --git a/libs/web-components/playground/src/pg-datepicker.svelte b/libs/web-components/playground/src/pg-datepicker.svelte new file mode 100644 index 000000000..cd572dcc8 --- /dev/null +++ b/libs/web-components/playground/src/pg-datepicker.svelte @@ -0,0 +1,35 @@ + + + + +

Selected Date

+{date?.toISOString()} + +
Single day with no initial date
+ + +
Single day with initial date
+ + +
Calendar
+ + +
Calendar
+ + +
+
Datepicker within a position:relative element
+ +
diff --git a/libs/web-components/playground/src/pg-dropdown.svelte b/libs/web-components/playground/src/pg-dropdown.svelte index 9bc1209ca..44bff06fc 100644 --- a/libs/web-components/playground/src/pg-dropdown.svelte +++ b/libs/web-components/playground/src/pg-dropdown.svelte @@ -10,20 +10,22 @@ native = detail.checked } + {}} value={value} disabled={false} leadingicon="airplane"> + + + - + {}} value={value} disabled={false} leadingicon="airplane"> + + + {}} value={value} disabled={false}> - - - - - - - - - - - + + + + {}} value={value} disabled={false}> + + {}} value={value} disabled={false}> diff --git a/libs/web-components/src/components/calendar/Calendar.html-data.json b/libs/web-components/src/components/calendar/Calendar.html-data.json new file mode 100644 index 000000000..06e956cf9 --- /dev/null +++ b/libs/web-components/src/components/calendar/Calendar.html-data.json @@ -0,0 +1,51 @@ +{ + "name": "goa-calendar", + "attributes": [ + { + "name": "name", + "description": "Name of the date property", + "type": "string" + }, + { + "name": "value", + "type": "string", + "description": "Selected date value" + }, + { + "name": "min", + "type": "string", + "description": "Min allowed date value" + }, + { + "name": "max", + "type": "string", + "description": "Max allowed date value" + }, + { + "name": "mt", + "description": "Top margin", + "valueSet": "spacing" + }, + { + "name": "mr", + "description": "Right margin", + "valueSet": "spacing" + }, + { + "name": "mb", + "description": "Bottom margin", + "valueSet": "spacing" + }, + { + "name": "ml", + "description": "Left margin", + "valueSet": "spacing" + } + ], + "references": [ + { + "name": "GoA Calendar", + "url": "{{siteUrl}}/iframe.html?viewMode=docs&id=components-calendar--basic&args=#calendar" + } + ] +} diff --git a/libs/web-components/src/components/calendar/Calendar.svelte b/libs/web-components/src/components/calendar/Calendar.svelte new file mode 100644 index 000000000..5d0a3bc49 --- /dev/null +++ b/libs/web-components/src/components/calendar/Calendar.svelte @@ -0,0 +1,433 @@ + + + + +
+ + + + {#each _months as month, i} + + {/each} + + + + + + {#each _years as year} + + {/each} + + + + +
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+ {#each _previousMonthDays as d} + + {/each} + {#each _monthDays as d} + + {/each} + {#each _nextMonthDays as d} + + {/each} +
+
+ + diff --git a/libs/web-components/src/components/calendar/calendar.spec.ts b/libs/web-components/src/components/calendar/calendar.spec.ts new file mode 100644 index 000000000..42de248e7 --- /dev/null +++ b/libs/web-components/src/components/calendar/calendar.spec.ts @@ -0,0 +1,330 @@ +import Calendar from "./Calendar.svelte"; +import { + createEvent, + fireEvent, + render, + waitFor, +} from "@testing-library/svelte"; +import { addDays, lastDayOfMonth, startOfDay } from "date-fns"; + +function toDayStart(d: Date): Date { + d.setHours(0); + d.setMinutes(0); + d.setSeconds(0); + d.setMilliseconds(0); + return d; +} + +it("it renders", async () => { + const { container, queryByTestId } = render(Calendar); + + const monthsEl = queryByTestId("months"); + const yearsEl = queryByTestId("years"); + + expect(monthsEl).toBeTruthy(); + expect(yearsEl).toBeTruthy(); + + const lastDate = toDayStart(lastDayOfMonth(new Date())); + + // date buttons + for (let i = 1; i <= lastDate.getDate(); i++) { + const d = new Date(lastDate); + d.setDate(i); + const dayEl = container + .querySelector(`[data-date="${d.getTime()}"]`) + .querySelector("[data-testid=date]"); + expect(dayEl).toBeTruthy(); + } + + // today's date + const today = toDayStart(new Date()); + const todayEl = container + .querySelector(`.today[data-date="${today.getTime()}"]`) + .querySelector("[data-testid=date]"); + expect(todayEl).toBeTruthy(); + + // months + const monthEls = + queryByTestId("months").querySelectorAll("goa-dropdown-item"); + + expect(monthEls.length).toBe(12); + for (let i = 0; i < 12; i++) { + const month = queryByTestId("months").querySelector( + `goa-dropdown-item[value="${i}"]`, + ); + expect(month).toBeTruthy(); + } +}); + +it("should have no date selected if one not provided", () => { + const { container } = render(Calendar); + + const selectedDate = container.querySelector(".selected"); + expect(selectedDate).toBeFalsy(); +}); + +it("sets the preset date value", async () => { + const date = new Date().toISOString(); + const { container } = render(Calendar, { value: date }); + + const timestamp = toDayStart(new Date(date)); + const dayEl = container + .querySelector(`.selected[data-date="${timestamp.getTime()}"]`) + .querySelector("[data-testid=date]"); + expect(dayEl).toBeTruthy(); +}); + +it("provides the defined year range", async () => { + const diff = 5; + const now = new Date(); + const min = new Date(now.getFullYear() - diff, now.getMonth(), now.getDate()); + const max = new Date(now.getFullYear() + diff, now.getMonth(), now.getDate()); + const { queryByTestId } = render(Calendar, { min, max }); + + const years = queryByTestId("years").querySelectorAll("goa-dropdown-item"); + + expect(years.length).toBe(11); // has to be one more than the count to include the first and last + for (let i = 0; i < diff * 2 + 1; i++) { + const year = queryByTestId("years").querySelector( + `goa-dropdown-item[value="${min.getFullYear() + i}"]`, + ); + expect(year).toBeTruthy(); + } +}); + +it("show the default year range", async () => { + const defaultDiff = 10; + const now = new Date(); + const min = new Date( + now.getFullYear() - defaultDiff, + now.getMonth(), + now.getDate(), + ); + const max = new Date( + now.getFullYear() + defaultDiff, + now.getMonth(), + now.getDate(), + ); + const { queryByTestId } = render(Calendar, { min, max }); + + const years = queryByTestId("years").querySelectorAll("goa-dropdown-item"); + + expect(years.length).toBe(21); // has to be one more than the count to include the first and last + for (let i = 0; i < defaultDiff * 2 + 1; i++) { + const year = queryByTestId("years").querySelector( + `goa-dropdown-item[value="${min.getFullYear() + i}"]`, + ); + expect(year).toBeTruthy(); + } +}); + +it("emits an event when a date is selected", async () => { + const name = "birthdate"; + const { container, queryByTestId } = render(Calendar, { name }); + + const today = toDayStart(new Date()); + const todayEl = container.querySelector( + `button.today[data-date="${today.getTime()}"]`, + ); + expect(todayEl).toBeTruthy(); + + const onChange = jest.fn(); + const calendarEl = queryByTestId("calendar"); + calendarEl.addEventListener("_change", (e) => { + onChange((e as CustomEvent).detail); + }); + + await fireEvent.click(todayEl); + await waitFor(() => { + expect(onChange).toBeCalled(); + expect(onChange).toBeCalledWith({ type: "date", value: today, name }); + }); +}); + +it("updates the calendar when a new month is selected", async () => { + const { container, queryByTestId } = render(Calendar); + + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const monthsEl = queryByTestId("months"); + + // validate the day of the first day for the current month + { + const date = toDayStart(new Date()); + date.setDate(1); + const dayOfWeek = date.getDay(); + const buttonEl = container.querySelector(`[data-date="${date.getTime()}"]`); + const dayEl = buttonEl.querySelector("[data-testid=date]"); + expect(dayEl.innerHTML).toBe("1"); + expect((buttonEl as HTMLElement).dataset.day).toBe(dayNames[dayOfWeek]); + } + + // change month + const otherMonthZeroIndex = (new Date().getMonth() + 1) % 11; + monthsEl.dispatchEvent( + new CustomEvent("_change", { + detail: { value: otherMonthZeroIndex }, + }), + ); + + await waitFor(() => { + const date = toDayStart(new Date()); + date.setMonth(otherMonthZeroIndex); + date.setDate(1); + const dayOfWeek = date.getDay(); + const buttonEl = container.querySelector(`[data-date="${date.getTime()}"]`); + const dayEl = buttonEl.querySelector("[data-testid=date]"); + expect(dayEl.innerHTML).toBe("1"); + expect((buttonEl as HTMLElement).dataset.day).toBe(dayNames[dayOfWeek]); + }); +}); + +it("updates the calendar when a new year is selected", async () => { + const { container, queryByTestId } = render(Calendar); + + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const yearsEl = queryByTestId("years"); + + // validate the day of the first day for the current month + { + const date = toDayStart(new Date()); + date.setDate(1); + const dayOfWeek = date.getDay(); + const buttonEl = container.querySelector(`[data-date="${date.getTime()}"]`); + const dayEl = buttonEl.querySelector("[data-testid=date]"); + expect(dayEl.innerHTML).toBe("1"); + expect((buttonEl as HTMLElement).dataset.day).toBe(dayNames[dayOfWeek]); + } + + // change year + const otherYearZeroIndex = new Date().getFullYear() + 1; + yearsEl.dispatchEvent( + new CustomEvent("_change", { + detail: { value: otherYearZeroIndex }, + }), + ); + + await waitFor(() => { + const date = toDayStart(new Date()); + date.setFullYear(otherYearZeroIndex); + date.setDate(1); + const dayOfWeek = date.getDay(); + const buttonEl = container.querySelector(`[data-date="${date.getTime()}"]`); + const dayEl = buttonEl.querySelector("[data-testid=date]"); + expect(dayEl).toBeTruthy(); + expect(dayEl.innerHTML).toBe("1"); + expect((buttonEl as HTMLElement).dataset.day).toBe(dayNames[dayOfWeek]); + }); +}); + +it("handle the arrow key presses", async () => { + const { container, queryByTestId } = render(Calendar, { value: new Date() }); + + let timestamp = toDayStart(new Date()); + const calendarEl = queryByTestId("calendar"); + + const arrowLeftEvent = createEvent.keyDown(calendarEl, { key: "ArrowLeft" }); + const arrowRightEvent = createEvent.keyDown(calendarEl, { + key: "ArrowRight", + }); + const arrowDownEvent = createEvent.keyDown(calendarEl, { key: "ArrowDown" }); + const arrowUpEvent = createEvent.keyDown(calendarEl, { key: "ArrowUp" }); + + // Left arrow + timestamp = addDays(timestamp, -1); + await fireEvent(calendarEl, arrowLeftEvent); + await waitFor(() => { + const current = container + .querySelector(`[tabindex="0"]`) + .querySelector("[data-testid=date]"); + expect(current.innerHTML).toBe(`${timestamp.getDate()}`); + }); + + // Right arrow + timestamp = addDays(timestamp, 1); + await fireEvent(calendarEl, arrowRightEvent); + await waitFor(() => { + const current = container + .querySelector(`[tabindex="0"]`) + .querySelector("[data-testid=date]"); + expect(current.innerHTML).toBe(`${timestamp.getDate()}`); + }); + + // Up arrow + timestamp = addDays(timestamp, -7); + await fireEvent(calendarEl, arrowUpEvent); + await waitFor(() => { + const current = container + .querySelector(`[tabindex="0"]`) + .querySelector("[data-testid=date]"); + expect(current.innerHTML).toBe(`${timestamp.getDate()}`); + }); + + // // Down arrow + timestamp = addDays(timestamp, 7); + await fireEvent(calendarEl, arrowDownEvent); + await waitFor(() => { + const current = container + .querySelector(`[tabindex="0"]`) + .querySelector("[data-testid=date]"); + expect(current.innerHTML).toBe(`${timestamp.getDate()}`); + }); +}); + +it("prevents date click selection outside of allowed range", async () => { + const min = new Date(); // today is the only date selectable + const max = new Date(); + const today = startOfDay(new Date()); + const { container, queryByTestId } = render(Calendar, { min, max }); + + const yesterday = addDays(today, -1); + const tomorrow = addDays(today, +1); + + const onChange = jest.fn(); + const calendarEl = queryByTestId("calendar"); + calendarEl.addEventListener("_change", (e) => { + onChange((e as CustomEvent).detail); + }); + + const yesterdayEl = container.querySelector( + `[data-date="${yesterday.getTime()}"]`, + ); + if (yesterdayEl) { + await fireEvent.click(yesterdayEl); + } + const tomorrowEl = container.querySelector( + `[data-date="${tomorrow.getTime()}"]`, + ); + if (tomorrowEl) { + await fireEvent.click(tomorrowEl); + } + + expect(onChange).not.toBeCalled(); +}); + +it("prevents date keyboard selection outside of allowed range", async () => { + const min = new Date(); // today is the only date selectable + const max = new Date(); + const { queryByTestId } = render(Calendar, { min, max }); + + const onChange = jest.fn(); + const calendarEl = queryByTestId("calendar"); + calendarEl.addEventListener("_change", (e) => { + onChange((e as CustomEvent).detail); + }); + + const arrowLeftEvent = createEvent.keyDown(calendarEl, { key: "ArrowLeft" }); + const arrowRightEvent = createEvent.keyDown(calendarEl, { + key: "ArrowRight", + }); + const arrowDownEvent = createEvent.keyDown(calendarEl, { key: "ArrowDown" }); + const arrowUpEvent = createEvent.keyDown(calendarEl, { key: "ArrowUp" }); + + await fireEvent(calendarEl, arrowLeftEvent); + await fireEvent(calendarEl, arrowRightEvent); + await fireEvent(calendarEl, arrowDownEvent); + await fireEvent(calendarEl, arrowUpEvent); + + await waitFor(() => { + expect(onChange).not.toBeCalled(); + }); +}); diff --git a/libs/web-components/src/components/calendar/doc.md b/libs/web-components/src/components/calendar/doc.md new file mode 100644 index 000000000..eb04678a4 --- /dev/null +++ b/libs/web-components/src/components/calendar/doc.md @@ -0,0 +1,10 @@ +# Calendar Library + + +Use it like this: + +``` + + + +``` diff --git a/libs/web-components/src/components/date-picker/DatePicker.html-data.json b/libs/web-components/src/components/date-picker/DatePicker.html-data.json new file mode 100644 index 000000000..a0f28c6f0 --- /dev/null +++ b/libs/web-components/src/components/date-picker/DatePicker.html-data.json @@ -0,0 +1,57 @@ +{ + "name": "goa-datepicker", + "attributes": [ + { + "name": "name", + "description": "Name of the date property", + "type": "string" + }, + { + "name": "value", + "type": "string", + "description": "Selected date value" + }, + { + "name": "min", + "type": "string", + "description": "Min allowed date value" + }, + { + "name": "max", + "type": "string", + "description": "Max allowed date value" + }, + { + "name": "relative", + "type": "string", + "defaultValue": "false", + "description": "Set to true when datepicker is nested in a position=relative element" + }, + { + "name": "mt", + "description": "Top margin", + "valueSet": "spacing" + }, + { + "name": "mr", + "description": "Right margin", + "valueSet": "spacing" + }, + { + "name": "mb", + "description": "Bottom margin", + "valueSet": "spacing" + }, + { + "name": "ml", + "description": "Left margin", + "valueSet": "spacing" + } + ], + "references": [ + { + "name": "GoA DatePicker", + "url": "{{siteUrl}}/iframe.html?viewMode=docs&id=components-datepicker--basic&args=#datepicker" + } + ] +} diff --git a/libs/web-components/src/components/date-picker/DatePicker.svelte b/libs/web-components/src/components/date-picker/DatePicker.svelte new file mode 100644 index 000000000..418899c02 --- /dev/null +++ b/libs/web-components/src/components/date-picker/DatePicker.svelte @@ -0,0 +1,124 @@ + + + + + dispatchValue(_date)} +> + + + diff --git a/libs/web-components/src/components/date-picker/date-picker.spec.ts b/libs/web-components/src/components/date-picker/date-picker.spec.ts new file mode 100644 index 000000000..e879cb7ec --- /dev/null +++ b/libs/web-components/src/components/date-picker/date-picker.spec.ts @@ -0,0 +1,155 @@ +import DatePicker from "./DatePicker.svelte"; +import { + createEvent, + fireEvent, + render, + waitFor, +} from "@testing-library/svelte"; +import { + addDays, + getDaysInMonth, + format, + getDaysInYear, + addMonths, + addYears, +} from "date-fns"; + +it("it renders", async () => { + const { container } = render(DatePicker); + + const popover = container.querySelector("goa-popover"); + const input = container.querySelector("goa-input"); + const calendar = container.querySelector("goa-calendar"); + + expect(popover).toBeTruthy(); + expect(input).toBeTruthy(); + expect(calendar).toBeTruthy(); +}); + +it("renders with props", async () => { + const value = addDays(new Date(), -10); + const relative = "true"; + + const { container } = render(DatePicker, { value, relative }); + + const popover = container.querySelector("goa-popover"); + const input = container.querySelector("goa-input"); + + expect(popover.getAttribute("relative")).toBe("true"); + expect(input.getAttribute("value")).toBe(format(value, "PPP")); +}); + +it("dispatches a value on date selection", async () => { + const { container } = render(DatePicker); + const popover = container.querySelector("goa-popover"); + const input = container.querySelector("goa-input"); + + const selectedDate = new Date(); + + const handler = jest.fn(); + popover.addEventListener("_change", ({ detail }: CustomEvent) => { + handler(); + expect(detail.value).toBe(selectedDate); + }); + + input.dispatchEvent( + new CustomEvent("_change", { + composed: true, + bubbles: true, + detail: { type: "date", value: selectedDate }, + }), + ); + + await waitFor(() => { + expect(handler).toBeCalled(); + }); +}); + +it("allows for date navigation via the keyboard", async () => { + const inputDate = new Date(); + let expected = new Date( + inputDate.getFullYear(), + inputDate.getMonth(), + inputDate.getDate(), + ); + + const { container } = render(DatePicker, { value: inputDate }); + const input = container.querySelector("goa-input"); + expect(inputDate).toBeTruthy(); + + const arrowLeftEvent = createEvent.keyDown(input, { key: "ArrowLeft" }); + const arrowRightEvent = createEvent.keyDown(input, { key: "ArrowRight" }); + const arrowDownEvent = createEvent.keyDown(input, { key: "ArrowDown" }); + const arrowUpEvent = createEvent.keyDown(input, { key: "ArrowUp" }); + const pageUpEvent = createEvent.keyDown(input, { key: "PageUp" }); + const pageDownEvent = createEvent.keyDown(input, { key: "PageDown" }); + const shiftPageUpEvent = createEvent.keyDown(input, { + key: "PageUp", + shiftKey: true, + }); + const shiftPageDownEvent = createEvent.keyDown(input, { + key: "PageDown", + shiftKey: true, + }); + + const handler = jest.fn(); + container.addEventListener("_change", ({ detail }: CustomEvent) => { + handler(detail.value); + }); + + // left arrow + expected = addDays(expected, -1); + await fireEvent(input, arrowLeftEvent); + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expected); + }); + + // right arrow + expected = addDays(expected, 1); + await fireEvent(input, arrowRightEvent); + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expected); + }); + + // up arrow + expected = addDays(expected, -7); + await fireEvent(input, arrowUpEvent); + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expected); + }); + + // down arrow + expected = addDays(expected, 7); + await fireEvent(input, arrowDownEvent); + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expected); + }); + + // page up + expected = addMonths(expected, -1); + await fireEvent(input, pageUpEvent); + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expected); + }); + + // page down + expected = addMonths(expected, 1); + await fireEvent(input, pageDownEvent); + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expected); + }); + + // shift page up + expected = addYears(expected, -1); + await fireEvent(input, shiftPageUpEvent); + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expected); + }); + + // shift page down + expected = addYears(expected, 1); + await fireEvent(input, shiftPageDownEvent); + await waitFor(() => { + expect(handler).toHaveBeenCalledWith(expected); + }); +}); diff --git a/libs/web-components/src/components/date-picker/doc.md b/libs/web-components/src/components/date-picker/doc.md new file mode 100644 index 000000000..4b248315e --- /dev/null +++ b/libs/web-components/src/components/date-picker/doc.md @@ -0,0 +1,11 @@ +# DatePicker Library + + +Use it like this: + +``` + + + + +``` diff --git a/libs/web-components/src/components/dropdown/Dropdown.spec.ts b/libs/web-components/src/components/dropdown/Dropdown.spec.ts index 0f648fbb6..094a4dd0b 100644 --- a/libs/web-components/src/components/dropdown/Dropdown.spec.ts +++ b/libs/web-components/src/components/dropdown/Dropdown.spec.ts @@ -1,30 +1,33 @@ -import '@testing-library/jest-dom'; -import { render, fireEvent, cleanup, waitFor } from '@testing-library/svelte'; -import GoADropdown from './Dropdown.svelte'; -import GoADropdownWrapper from './DropdownWrapper.test.svelte'; +import "@testing-library/jest-dom"; +import { render, fireEvent, cleanup, waitFor } from "@testing-library/svelte"; +import GoADropdown from "./Dropdown.svelte"; +import GoADropdownWrapper from "./DropdownWrapper.test.svelte"; afterEach(() => { - cleanup() + cleanup(); jest.clearAllMocks(); }); -describe('GoADropdown', () => { - const name = 'favcolor'; +describe("GoADropdown", () => { + const name = "favcolor"; const items = ["red", "blue", "orange"]; - it('dropdown should render', async () => { + it("dropdown should render", async () => { // WHEN - const result = render(GoADropdownWrapper, { name, id: "color", value: 'orange', items }); + const result = render(GoADropdownWrapper, { + name, + id: "color", + value: "orange", + items, + }); // THEN const dropdown = result.queryByTestId("favcolor-dropdown"); - expect(dropdown.getAttribute("style")).toContain("--width: 20ch"); const popover = result.container.querySelector("goa-popover"); expect(popover.getAttribute("disabled")).toBe("false"); expect(popover.getAttribute("open")).toBe("false"); expect(popover.getAttribute("padded")).toBe("false"); expect(popover.getAttribute("relative")).toBe("false"); - expect(popover.getAttribute("width")).toBe("20ch"); // Equals with computed width - const inputField = dropdown.querySelector('input'); + const inputField = dropdown.querySelector("input"); expect(inputField.getAttribute("id")).toBe("color"); expect(inputField.getAttribute("aria-autocomplete")).toBe("list"); expect(inputField.getAttribute("aria-controls")).toBe("menu-color"); @@ -52,37 +55,48 @@ describe('GoADropdown', () => { // Check options for (let index = 0; index < items.length; index++) { const option = result.container.querySelector("li#" + items[index]); - expect(option.getAttribute("aria-selected")).toBe('orange' === items[index] ? "true": "false");// pre-selected by value + expect(option.getAttribute("aria-selected")).toBe( + "orange" === items[index] ? "true" : "false", + ); // pre-selected by value expect(option.getAttribute("data-index")).toBe(`${index}`); - expect(option.getAttribute("data-testid")).toBe("dropdown-item-" + items[index]); + expect(option.getAttribute("data-testid")).toBe( + "dropdown-item-" + items[index], + ); expect(option.getAttribute("data-value")).toBe(items[index]); expect(option.getAttribute("role")).toBe("option"); expect(option).toHaveTextContent(items[index]); } // show menu await fireEvent.click(dropdownIcon); - await waitFor(() => { - // Menu should open - expect(popover.getAttribute("open")).toBe("true"); - expect(inputField.getAttribute("aria-owns")).toBe("menu-color"); // Menu is displayed - expect(dropdownIcon.getAttribute("ariaexpanded")).toBe("true"); - expect(dropdownIcon.getAttribute("type")).toBe("chevron-up"); - }, { timeout: 100 }) + await waitFor( + () => { + // Menu should open + expect(popover.getAttribute("open")).toBe("true"); + expect(inputField.getAttribute("aria-owns")).toBe("menu-color"); // Menu is displayed + expect(dropdownIcon.getAttribute("ariaexpanded")).toBe("true"); + expect(dropdownIcon.getAttribute("type")).toBe("chevron-up"); + }, + { timeout: 100 }, + ); }); - it('dropdown filterable should render', async() => { + it("dropdown filterable should render", async () => { // WHEN - const result = render(GoADropdownWrapper, { name, id: "color", value: 'orange', items, filterable: true }); + const result = render(GoADropdownWrapper, { + name, + id: "color", + value: "orange", + items, + filterable: true, + }); // THEN const dropdown = result.queryByTestId("favcolor-dropdown"); - expect(dropdown.getAttribute("style")).toContain("--width: 20ch"); const popover = result.container.querySelector("goa-popover"); expect(popover.getAttribute("disabled")).toBe("false"); expect(popover.getAttribute("open")).toBe("false"); expect(popover.getAttribute("padded")).toBe("false"); expect(popover.getAttribute("relative")).toBe("false"); - expect(popover.getAttribute("width")).toBe("20ch"); // Equals with computed width - const inputField = dropdown.querySelector('input'); + const inputField = dropdown.querySelector("input"); expect(inputField.getAttribute("id")).toBe("color"); expect(inputField.getAttribute("aria-autocomplete")).toBe("list"); expect(inputField.getAttribute("aria-controls")).toBe("menu-color"); @@ -110,36 +124,43 @@ describe('GoADropdown', () => { // Check options for (let index = 0; index < items.length; index++) { const option = result.container.querySelector("li#" + items[index]); - expect(option.getAttribute("aria-selected")).toBe('orange' === items[index] ? "true": "false");// pre-selected by value + expect(option.getAttribute("aria-selected")).toBe( + "orange" === items[index] ? "true" : "false", + ); // pre-selected by value expect(option.getAttribute("data-index")).toBe(`${index}`); - expect(option.getAttribute("data-testid")).toBe("dropdown-item-" + items[index]); + expect(option.getAttribute("data-testid")).toBe( + "dropdown-item-" + items[index], + ); expect(option.getAttribute("data-value")).toBe(items[index]); expect(option.getAttribute("role")).toBe("option"); expect(option).toHaveTextContent(items[index]); } // show menu await fireEvent.click(dropdownIcon); - await waitFor(() => { - // Menu should open - expect(popover.getAttribute("open")).toBe("true"); - expect(inputField.getAttribute("aria-owns")).toBe("menu-color"); // Menu is displayed - expect(dropdownIcon.getAttribute("ariaexpanded")).toBe("true"); - expect(dropdownIcon.getAttribute("type")).toBe("chevron-up"); - }, { timeout: 100 }) - }) + await waitFor( + () => { + // Menu should open + expect(popover.getAttribute("open")).toBe("true"); + expect(inputField.getAttribute("aria-owns")).toBe("menu-color"); // Menu is displayed + expect(dropdownIcon.getAttribute("ariaexpanded")).toBe("true"); + expect(dropdownIcon.getAttribute("type")).toBe("chevron-up"); + }, + { timeout: 100 }, + ); + }); describe("single selection", () => { - it('selects a value when clicking on the option', async () => { + it("selects a value when clicking on the option", async () => { const onClick = jest.fn(); const result = render(GoADropdownWrapper, { name, items }); let selectedValue = ""; const dropdown = result.queryByTestId("favcolor-dropdown"); - dropdown.addEventListener('_change', (e) => { + dropdown.addEventListener("_change", (e) => { const { name: n, value } = e["detail"]; selectedValue = value; expect(n).toBe("favcolor"); onClick(); - }) + }); const dropdownIcon = result.container.querySelector("goa-icon"); // show menu @@ -154,12 +175,16 @@ describe('GoADropdown', () => { expect(option.getAttribute("aria-selected")).toBe("true"); }); }); - it("search for a query (filterable)", async() => { - const result = render(GoADropdownWrapper, { name, items, filterable: true }); + it("search for a query (filterable)", async () => { + const result = render(GoADropdownWrapper, { + name, + items, + filterable: true, + }); const input = result.container.querySelector("input"); - await fireEvent.keyDown(input, {key: 'b', code: 'b'}); - await fireEvent.input(input, {target: {value: 'b'}}); + await fireEvent.keyDown(input, { key: "b", code: "b" }); + await fireEvent.input(input, { target: { value: "b" } }); await waitFor(async () => { // When type in the input, will open the suggestion @@ -179,75 +204,100 @@ describe('GoADropdown', () => { }); describe("filter options edge cases", () => { it.each` - query | expectedOption | notOption - ${'white wh'} | ${'White Whale'} | ${'White Wine'} - ${'al'} | ${'Alabama'} | ${'Whale'} - ${'b c'} | ${'null'} | ${'BC'} - ${'red '} | ${'null'} | ${'Red'} - ${'a s'} | ${'null'} | ${'American Samoa'} - ${'american samoa w'} | ${'null'} | ${'American Samoa'} - ${'american samoa '} | ${'null'} | ${'American Samoa'} - `(`search for '$query' should return '$expectedOption', not '$notOption'}`, async ({ query, expectedOption}) => { - // GIVEN - const options = [ - "White Wine", - "White Whale", - "American Samoa", - "Alabama", - "Whale", - "Red", - "BC" - ]; - - const result = render(GoADropdownWrapper, { name, items: options, filterable: true }); - // WHEN - const input = result.container.querySelector("input"); - await fireEvent.keyDown(input, {key: query[0], code: query[0]}); // enough to trigger the event - await fireEvent.input(input, {target: {value: query}}); - - await waitFor(async () => { - // When type in the input, will open the suggestion - const popover = result.container.querySelector("goa-popover"); - expect(popover.getAttribute("open")).toBe("true"); - - const liElements = result.container.querySelectorAll("li"); - expect(liElements.length).toBe(1); - if (expectedOption === 'null') { - expect(liElements[0].getAttribute("data-testid")).toBe("dropdown-item-not-found"); - } else { - expect(liElements[0].getAttribute("data-value")).toBe(expectedOption); - } - - }); - }); - }) - it('can be selected programmatically', async() => { - const result = render(GoADropdownWrapper, {name, value: 'blue', items}); + query | expectedOption | notOption + ${"white wh"} | ${"White Whale"} | ${"White Wine"} + ${"al"} | ${"Alabama"} | ${"Whale"} + ${"b c"} | ${"null"} | ${"BC"} + ${"red "} | ${"null"} | ${"Red"} + ${"a s"} | ${"null"} | ${"American Samoa"} + ${"american samoa w"} | ${"null"} | ${"American Samoa"} + ${"american samoa "} | ${"null"} | ${"American Samoa"} + `( + `search for '$query' should return '$expectedOption', not '$notOption'}`, + async ({ query, expectedOption }) => { + // GIVEN + const options = [ + "White Wine", + "White Whale", + "American Samoa", + "Alabama", + "Whale", + "Red", + "BC", + ]; + + const result = render(GoADropdownWrapper, { + name, + items: options, + filterable: true, + }); + // WHEN + const input = result.container.querySelector("input"); + await fireEvent.keyDown(input, { key: query[0], code: query[0] }); // enough to trigger the event + await fireEvent.input(input, { target: { value: query } }); + + await waitFor(async () => { + // When type in the input, will open the suggestion + const popover = result.container.querySelector("goa-popover"); + expect(popover.getAttribute("open")).toBe("true"); + + const liElements = result.container.querySelectorAll("li"); + expect(liElements.length).toBe(1); + if (expectedOption === "null") { + expect(liElements[0].getAttribute("data-testid")).toBe( + "dropdown-item-not-found", + ); + } else { + expect(liElements[0].getAttribute("data-value")).toBe( + expectedOption, + ); + } + }); + }, + ); + }); + it("can be selected programmatically", async () => { + const result = render(GoADropdownWrapper, { name, value: "blue", items }); const button = result.container.querySelector("button"); - await waitFor(async() => { + await waitFor(async () => { await fireEvent.click(button); // validate the selected item - const selected = result.container.querySelector("li[aria-selected=true]"); + const selected = result.container.querySelector( + "li[aria-selected=true]", + ); expect(selected).not.toBeNull(); expect(selected.innerHTML).toContain("orange"); }); - }) - it('a blank value can be selected programmatically', async() => { - const result = render(GoADropdownWrapper, {name, value: 'blue', items, resetValue: ""}); + }); + it("a blank value can be selected programmatically", async () => { + const result = render(GoADropdownWrapper, { + name, + value: "blue", + items, + resetValue: "", + }); const resetButton = result.container.querySelector("button"); - await waitFor(async() => { + await waitFor(async () => { await fireEvent.click(resetButton); // validate the selected item - const selected = result.container.querySelector("li[aria-selected=true]"); + const selected = result.container.querySelector( + "li[aria-selected=true]", + ); expect(selected).toBe(null); // No options should be selected }); - }) - it('clear the input and open the menu when click clear icon', async() => { - const result = render(GoADropdownWrapper, {name, value: 'blue', items, filterable: true, resetValue: "orange"}); + }); + it("clear the input and open the menu when click clear icon", async () => { + const result = render(GoADropdownWrapper, { + name, + value: "blue", + items, + filterable: true, + resetValue: "orange", + }); const button = result.container.querySelector("button"); await fireEvent.click(button); await waitFor(() => {}, { timeout: 1 }); @@ -261,7 +311,6 @@ describe('GoADropdown', () => { dropdown.addEventListener("_change", (e) => { const { name: n, value } = e["detail"]; selectedValue = value; - console.log("selectedValue", selectedValue); expect(n).toBe("favcolor"); onChangeMock(); }); @@ -274,14 +323,14 @@ describe('GoADropdown', () => { expect(liElements.length).toBe(3); // Clear icon is hidden const icon = result.container.querySelector("goa-icon"); - expect(icon.getAttribute("type")).toBe("chevron-up");// dropdown icon is displayed and menu is opened + expect(icon.getAttribute("type")).toBe("chevron-up"); // dropdown icon is displayed and menu is opened expect(onChangeMock).toHaveBeenCalledTimes(1); expect(selectedValue).toEqual(""); }); - }) + }); describe("disabled", () => { - it('can be enabled', async () => { + it("can be enabled", async () => { const result = render(GoADropdownWrapper, { name, disabled: false, @@ -291,17 +340,17 @@ describe('GoADropdown', () => { const dropdownIcon = result.container.querySelector("goa-icon"); await fireEvent.click(dropdownIcon); - const onClick = jest.fn() - dropdown.addEventListener('_change', () => { + const onClick = jest.fn(); + dropdown.addEventListener("_change", () => { onClick(); - }) + }); await waitFor(async () => { const menu = result.queryByTestId("dropdown-menu"); - expect(menu).toBeVisible() + expect(menu).toBeVisible(); }); }); - it('can be disabled', async () => { + it("can be disabled", async () => { const result = render(GoADropdownWrapper, { name, disabled: true, @@ -320,7 +369,7 @@ describe('GoADropdown', () => { }); describe("error state", () => { - it('does not show an error state', async () => { + it("does not show an error state", async () => { const result = render(GoADropdownWrapper, { name, error: false, @@ -328,56 +377,62 @@ describe('GoADropdown', () => { }); const dropdown = result.queryByTestId("favcolor-dropdown"); - const inputField = dropdown.querySelector('input'); + const inputField = dropdown.querySelector("input"); // show menu await fireEvent.focus(inputField); - const inputGroupDiv = result.container.querySelector("div.dropdown-input-group"); + const inputGroupDiv = result.container.querySelector( + "div.dropdown-input-group", + ); expect(inputGroupDiv.getAttribute("class")).not.toContain("error"); }); - it('shows an error state', async () => { + it("shows an error state", async () => { const result = render(GoADropdown, { name, error: true, }); const dropdown = result.queryByTestId("favcolor-dropdown"); - const inputField = dropdown.querySelector('input'); + const inputField = dropdown.querySelector("input"); // show menu await fireEvent.focus(inputField); - const inputGroupDiv = result.container.querySelector("div.dropdown-input-group"); + const inputGroupDiv = result.container.querySelector( + "div.dropdown-input-group", + ); expect(inputGroupDiv.getAttribute("class")).toContain("error"); }); - }) + }); describe("leading icon", () => { - it('does not show a leading icon', async () => { + it("does not show a leading icon", async () => { const result = render(GoADropdownWrapper, { name, items }); const leadingIcon = result.queryByTestId("leading-icon"); expect(leadingIcon).toBeNull(); }); - it('shows a leading icon', async () => { + it("shows a leading icon", async () => { const result = render(GoADropdownWrapper, { name, leadingicon: "add", items, }); const leadingIcon = result.queryByTestId("leading-icon"); - expect(leadingIcon.getAttribute("class")).toContain("dropdown-input--leading-icon"); + expect(leadingIcon.getAttribute("class")).toContain( + "dropdown-input--leading-icon", + ); expect(leadingIcon.getAttribute("type")).toBe("add"); }); - }) + }); describe("placeholder", () => { - it('does not show a placeholder', async () => { + it("does not show a placeholder", async () => { const result = render(GoADropdownWrapper, { name, items }); const input = result.container.querySelector("input"); expect(input.getAttribute("placeholder")).toBe(""); }); - it('shows a placeholder', async () => { + it("shows a placeholder", async () => { const result = render(GoADropdownWrapper, { name, placeholder: "some text", @@ -386,16 +441,35 @@ describe('GoADropdown', () => { const input = result.container.querySelector("input"); expect(input.getAttribute("placeholder")).toBe("some text"); }); - }) + }); describe("width", () => { - it("dropdown should have the default width", async () => { - const result = render(GoADropdownWrapper, { name, items }); + it("has default width", async () => { + const result = render(GoADropdownWrapper, { + name, + items: ["1", "2", "3"], + }); + const popover = result.container.querySelector("goa-popover"); + expect(popover.getAttribute("width")).toBe("9ch"); // 8 + 1 (letter count of longest item) + }); - const dropdown = result.queryByTestId("favcolor-dropdown"); - expect(dropdown.getAttribute("style")).toContain("--width: 20ch"); + it("has width of longest item", async () => { + const result = render(GoADropdownWrapper, { + name, + items: ["1", "2", "20chars============="], + }); const popover = result.container.querySelector("goa-popover"); - expect(popover.getAttribute("width")).toBe("20ch"); // Equals with computed width + expect(popover.getAttribute("width")).toBe("28ch"); // 8 + 20 + }); + + it("width increased due to leading icon", async () => { + const result = render(GoADropdownWrapper, { + name, + leadingicon: "airplane", + items: ["1", "2", "3"], + }); + const popover = result.container.querySelector("goa-popover"); + expect(popover.getAttribute("width")).toBe("11ch"); // 8 + 1 (letter count) + 2 (icon width) }); it("uses the non-percent width supplied", async () => { @@ -408,7 +482,9 @@ describe('GoADropdown', () => { expect(dropdown.getAttribute("style")).toContain("--width: 500px"); const popover = result.container.querySelector("goa-popover"); expect(popover.getAttribute("width")).toBe("500px"); // Equals with computed width - const inputGroup = result.container.querySelector(".dropdown-input-group"); + const inputGroup = result.container.querySelector( + ".dropdown-input-group", + ); expect(inputGroup.getAttribute("style")).toContain("width: 500px"); }); @@ -422,10 +498,12 @@ describe('GoADropdown', () => { expect(dropdown.getAttribute("style")).toContain("--width: 100%"); const popover = result.container.querySelector("goa-popover"); expect(popover.getAttribute("width")).toBe("100%"); // Equals with computed width - const inputGroup = result.container.querySelector("div.dropdown-input-group"); + const inputGroup = result.container.querySelector( + "div.dropdown-input-group", + ); expect(inputGroup.getAttribute("style")).toContain("width: 100%"); }); - }) + }); describe("maxheight", () => { it("uses the default max height", async () => { @@ -435,8 +513,8 @@ describe('GoADropdown', () => { const menu = result.queryByTestId("dropdown-menu"); await waitFor(() => { - expect(menu).toHaveStyle("max-height: 276px"); // 276px is default value - }) + expect(menu).toHaveStyle("max-height: 276px"); // 276px is default value + }); }); it("uses the height when supplied", async () => { @@ -454,7 +532,7 @@ describe('GoADropdown', () => { expect(menu).toHaveStyle("max-height: 400px"); }); }); - }) + }); describe("aria-labels", () => { it("show the aria label", async () => { @@ -462,7 +540,7 @@ describe('GoADropdown', () => { name, items, value: "orange", - arialabel: 'Favourite Color', + arialabel: "Favourite Color", }); const input = result.container.querySelector("input"); expect(input.getAttribute("aria-label")).toBe("Favourite Color"); @@ -471,7 +549,7 @@ describe('GoADropdown', () => { const menu = result.container.querySelector("ul"); expect(menu.getAttribute("aria-label")).toBe("Favourite Color"); }); - }) + }); describe("arialabelledby", () => { it("renders the arialabelledby", async () => { @@ -479,7 +557,7 @@ describe('GoADropdown', () => { name, items, value: "orange", - arialabelledby: 'Favourite Color', + arialabelledby: "Favourite Color", }); const input = result.container.querySelector("input"); expect(input.getAttribute("aria-labelledby")).toBe("Favourite Color"); @@ -488,14 +566,17 @@ describe('GoADropdown', () => { const menu = result.container.querySelector("ul"); expect(menu.getAttribute("aria-labelledby")).toBe("Favourite Color"); }); - }) + }); describe.skip("keyboard bindings", () => { - const space = new KeyboardEvent('keydown', { - keyCode: 32, key: " ", code: "Space" + const space = new KeyboardEvent("keydown", { + keyCode: 32, + key: " ", + code: "Space", }); - const enter = new KeyboardEvent('keydown', { - keyCode: 13, key: "Enter" + const enter = new KeyboardEvent("keydown", { + keyCode: 13, + key: "Enter", }); // const downArrow = new KeyboardEvent('keydown', { // keyCode: 40, key: "ArrowDown", code: "ArrowDown" @@ -510,22 +591,24 @@ describe('GoADropdown', () => { // altKey: true, keyCode: 38, key: "ArrowUp", code: "ArrowUp" // }); - for (const event of [space, enter]) { it(`should show the dropdown menu on <${event.key}>`, async () => { - // const user = {}; - const result = render(GoADropdown, { name: 'favcolor', items, value: "red" }); + const result = render(GoADropdown, { + name: "favcolor", + items, + value: "red", + }); // const dropdown = result.queryByTestId("favcolor-dropdown"); - const input = result.queryByTestId('goa-input'); + const input = result.queryByTestId("goa-input"); // == Focus menu // await fireEvent.focus(input); // // input.focus(); // expect(input).toHaveFocus(); - await fireEvent.click(input) + await fireEvent.click(input); // == Open menu // method 1 @@ -536,8 +619,8 @@ describe('GoADropdown', () => { // document.dispatchEvent(event); const menu = result.queryByTestId("dropdown-menu"); await waitFor(() => { - expect(menu.classList).toContain("dropdown-active") - }) + expect(menu.classList).toContain("dropdown-active"); + }); // == Select third item // method 1 @@ -557,12 +640,12 @@ describe('GoADropdown', () => { const menuItem = result.queryByTestId("dropdown-item-pink"); await waitFor(() => { - expect(input.innerHTML).toContain("pink") + expect(input.innerHTML).toContain("pink"); expect(menuItem.getAttribute("aria-selected")).toBe("true"); - }) - }) + }); + }); } - }) + }); describe("Margins", () => { it(`should add the margin`, async () => { @@ -590,27 +673,34 @@ describe('GoADropdown', () => { value: "green", native: true, items, - id: "color" - }) - expect(container.querySelector("select").getAttribute("id")).toBe("color"); + id: "color", + }); + expect(container.querySelector("select").getAttribute("id")).toBe( + "color", + ); await waitFor(() => { - const options = container.querySelectorAll("select option") - expect(options.length).toBe(3) + const options = container.querySelectorAll("select option"); + expect(options.length).toBe(3); }); - }) + }); it("dispatches the event on selection", async () => { - const { container } = render(GoADropdownWrapper, { name, value: "green", native: true, items }) + const { container } = render(GoADropdownWrapper, { + name, + value: "green", + native: true, + items, + }); const onChange = jest.fn(); - const select = container.querySelector("select") + const select = container.querySelector("select"); select.addEventListener("_change", (e: CustomEvent) => { const { name: _name, value } = e.detail; expect(_name).toBe(name); expect(value).toBe("blue"); - onChange(_name, value) - }) + onChange(_name, value); + }); // This is the only way I can get the test passing, but it doesn't ensure // that the event binding is correct @@ -630,76 +720,95 @@ describe('GoADropdown', () => { // expect(option).toBeTruthy(); // await fireEvent.click(option) await waitFor(async () => { - expect(onChange).toBeCalled() - }) - }) - }) + expect(onChange).toBeCalled(); + }); + }); + }); it("shows the label text when provided", async () => { - const { container } = render(GoADropdownWrapper, { name, value: "green", native: true, items }) + const { container } = render(GoADropdownWrapper, { + name, + value: "green", + native: true, + items, + }); await waitFor(() => { - const options = container.querySelectorAll("select option") + const options = container.querySelectorAll("select option"); expect(options.length).toBe(3); for (let index = 0; index < items.length; index++) { - expect(options[index].textContent.trim()).toBe(items[index]) + expect(options[index].textContent.trim()).toBe(items[index]); } }); - }) + }); it("shows the value when no label is provided", async () => { - const { container } = render(GoADropdownWrapper, { name, value: "green", native: true, items }) + const { container } = render(GoADropdownWrapper, { + name, + value: "green", + native: true, + items, + }); await waitFor(() => { - const options = container.querySelectorAll("select option") - expect(options.length).toBe(3) + const options = container.querySelectorAll("select option"); + expect(options.length).toBe(3); for (let index = 0; index < items.length; index++) { expect(options[index].textContent.trim()).toBe(items[index]); } }); - }) + }); it("renders disabled state", async () => { - const { container } = render(GoADropdownWrapper, { name, native: true, disabled: true, items }) + const { container } = render(GoADropdownWrapper, { + name, + native: true, + disabled: true, + items, + }); await waitFor(() => { - const el = container.querySelector("select:disabled") + const el = container.querySelector("select:disabled"); expect(el).toBeTruthy(); }); - }) + }); it("renders an error state", async () => { - const { container } = render(GoADropdownWrapper, { name, native: true, error: true, items }) + const { container } = render(GoADropdownWrapper, { + name, + native: true, + error: true, + items, + }); await waitFor(() => { - const el = container.querySelector("select.error") + const el = container.querySelector("select.error"); expect(el).toBeTruthy(); }); - }) - }) + }); + }); describe("dynamic children items", () => { // FIXME: Unable to get the parent's `slotchanged` event to fire it.skip("should update the option items on dynamic changes", async () => { - const { container } = render(GoADropdown, { name }) + const { container } = render(GoADropdown, { name }); await waitFor(() => { - const children = container.querySelectorAll("li") + const children = container.querySelectorAll("li"); expect(children.length).toBe(0); }); - const child = document.createElement("goa-dropdown-item") - child.setAttribute("value", "red") - child.setAttribute("label", "Red") - const shadow = container.attachShadow({ mode: "open" }) - shadow.appendChild(child) + const child = document.createElement("goa-dropdown-item"); + child.setAttribute("value", "red"); + child.setAttribute("label", "Red"); + const shadow = container.attachShadow({ mode: "open" }); + shadow.appendChild(child); await waitFor(() => { - const children = container.querySelectorAll("li") + const children = container.querySelectorAll("li"); expect(children.length).toBe(1); }); - }) - }) + }); + }); }); - diff --git a/libs/web-components/src/components/dropdown/Dropdown.svelte b/libs/web-components/src/components/dropdown/Dropdown.svelte index 06cd4ab20..785ce8c12 100644 --- a/libs/web-components/src/components/dropdown/Dropdown.svelte +++ b/libs/web-components/src/components/dropdown/Dropdown.svelte @@ -49,28 +49,26 @@ let _inputValue: string = ""; let _isMenuVisible = false; let _highlightedIndex: number = -1; // keep track highlighted option, for arrow up/down - let _computedWidth: string; + let _width: string; let _selectedOption = undefined; // to keep track if value is matched to combobox option let _previousSelectedValue = ""; // to keep track if value is changed from previously selected value - for clear button let _el: HTMLElement; let _menuEl: HTMLElement; let _selectEl: HTMLSelectElement; - let _popOverEl: HTMLElement; let _inputEl: HTMLInputElement; // Reactive statement $: if (_el) { - _values = parseValues(value); - _options = getOptions(); - if (!_native) { - _computedWidth = getCustomDropdownWidth(_options); - } - if (_options.length) { - // Keep track of initialized value - updateSelectedValue(value); - } + _values = parseValues(value); + _options = getOptions(); + if (!_native) { + _width = width || getCustomDropdownWidth(_options); + } + if (_options.length) { + updateSelectedValue(value); + } } afterUpdate(() => { @@ -116,22 +114,25 @@ } // compute the required width to enure all children fit - function getCustomDropdownWidth(options: Option[]) { - let width: string; - let maxCount = 0; - - if (options.length === 0 && placeholder !== "") { - return `${placeholder.length + 12}ch`; + function getCustomDropdownWidth(options: Option[]): string { + // set width to longest item + const optionsWidth = options + .map((option: Option) => { + const label = `${option.label}` || `${option.value}` || ""; + return label.length; + }) + .sort((a: number, b:number) => a > b ? 1 : -1) + .pop(); + + // longest one defines the width + let maxWidth = Math.max(optionsWidth, placeholder.length) + 8; + + // compensate for icon width + if (leadingicon) { + maxWidth += 2; } - options.forEach((option: Option) => { - const label = option.label || option.value || ""; - if (!width && maxCount < label.length) { - maxCount = label.length; - width = `${Math.max(20, maxCount + 12)}ch`; - } - }); - return width; + return `${maxWidth}ch`; } function isOptionInView(node: HTMLLIElement): boolean { @@ -266,9 +267,8 @@ */ if (query.endsWith(" ") || queryWords.length > 1) { if (queryWords.length !== filterWords.length) return false; - const isLastWord = (word: string, index: number, array: string[]) => index === array.length - 1; return queryWords.every((word, index) => - isLastWord(word, index, queryWords) + index === queryWords.length - 1 ? filterWords[index].startsWith(word) // last word should be prefix match: american sa ==> american samoa : filterWords[index] === word // other words should be exact match: b c should not match bc ); @@ -531,7 +531,7 @@ class:dropdown-native={_native} style={` ${calculateMargin(mt, mr, mb, ml)} - --width: ${width || _computedWidth} + --width: ${_width} `} bind:this={_el} > @@ -563,8 +563,7 @@ {relative} open={_isMenuVisible} padded="false" - width={width || _computedWidth} - bind:this={_popOverEl} + width={_width} tabindex="-1" on:_open={showMenu} on:_close={closeMenu} diff --git a/libs/web-components/src/index.ts b/libs/web-components/src/index.ts index bc3af14c3..dca3cec33 100644 --- a/libs/web-components/src/index.ts +++ b/libs/web-components/src/index.ts @@ -4,7 +4,7 @@ export * from "./components/accordion/Accordion.svelte"; export * from "./components/app-header/AppHeader.svelte"; export * from "./components/badge/Badge.svelte"; export * from "./components/block/Block.svelte"; -export * from './components/block/Block.svelte'; +export * from "./components/block/Block.svelte"; export * from "./components/button/Button.svelte"; export * from "./components/button-group/ButtonGroup.svelte"; export * from "./components/callout/Callout.svelte"; @@ -17,7 +17,7 @@ export * from "./components/checkbox/Checkbox.svelte"; export * from "./components/chip/Chip.svelte"; export * from "./components/circular-progress/CircularProgress.svelte"; export * from "./components/container/Container.svelte"; -export * from './components/details/Details.svelte'; +export * from "./components/details/Details.svelte"; export * from "./components/divider/Divider.svelte"; export * from "./components/dropdown/Dropdown.svelte"; export * from "./components/dropdown/DropdownItem.svelte"; @@ -35,16 +35,16 @@ export * from "./components/microsite-header/MicrositeHeader.svelte"; export * from "./components/modal/Modal.svelte"; export * from "./components/notification/Notification.svelte"; export * from "./components/page-block/PageBlock.svelte"; -export * from './components/pagination/Pagination.svelte'; -export * from './components/popover/Popover.svelte'; +export * from "./components/pagination/Pagination.svelte"; +export * from "./components/popover/Popover.svelte"; export * from "./components/radio-group/RadioGroup.svelte"; export * from "./components/scrollable/Scrollable.svelte"; export * from "./components/skeleton/Skeleton.svelte"; export * from "./components/spacer/Spacer.svelte"; -export * from './components/spacer/Spacer.svelte'; +export * from "./components/spacer/Spacer.svelte"; export * from "./components/spinner/Spinner.svelte"; -export * from './components/table/Table.svelte'; -export * from './components/table/TableSortHeader.svelte'; +export * from "./components/table/Table.svelte"; +export * from "./components/table/TableSortHeader.svelte"; export * from "./components/text-area/TextArea.svelte"; export * from "./components/tooltip/Tooltip.svelte"; export * from "./layouts/FullScreenNavbarLayout.svelte"; @@ -55,17 +55,17 @@ export * from "./layouts/three-column-layout/ThreeColumnLayout.svelte"; export * from "./components/_experimental/sidebar/Sidebar.svelte"; export * from "./components/_experimental/sidebar/SidebarItem.svelte"; -export { default as Pagination } from './components/pagination/Pagination.svelte'; -export { default as FormStepper } from './components/form-stepper/FormStepper.svelte'; -export { default as FormStep } from './components/form-step/FormStep.svelte'; -export { default as Pages } from './components/pages/Pages.svelte'; -export { default as FileUploadInput } from './components/file-upload-input/FileUploadInput.svelte'; -export { default as FileUploadCard } from './components/file-upload-card/FileUploadCard.svelte'; - -export { default as AppHeaderMenu } from './components/app-header-menu/AppHeaderMenu.svelte'; -export { default as SideMenu } from './components/side-menu/SideMenu.svelte'; -export { default as SideMenuGroup } from './components/side-menu-group/SideMenuGroup.svelte'; -export { default as SideMenuHeading } from './components/side-menu-heading/SideMenuHeading.svelte'; -export { default as Tabs } from './components/tabs/Tabs.svelte'; -export { default as Tab } from './components/tab/Tab.svelte'; - +export { default as AppHeaderMenu } from "./components/app-header-menu/AppHeaderMenu.svelte"; +export { default as Calendar } from "./components/calendar/Calendar.svelte"; +export { default as DatePicker } from "./components/date-picker/DatePicker.svelte"; +export { default as FileUploadCard } from "./components/file-upload-card/FileUploadCard.svelte"; +export { default as FileUploadInput } from "./components/file-upload-input/FileUploadInput.svelte"; +export { default as FormStep } from "./components/form-step/FormStep.svelte"; +export { default as FormStepper } from "./components/form-stepper/FormStepper.svelte"; +export { default as Pages } from "./components/pages/Pages.svelte"; +export { default as Pagination } from "./components/pagination/Pagination.svelte"; +export { default as SideMenu } from "./components/side-menu/SideMenu.svelte"; +export { default as SideMenuGroup } from "./components/side-menu-group/SideMenuGroup.svelte"; +export { default as SideMenuHeading } from "./components/side-menu-heading/SideMenuHeading.svelte"; +export { default as Tab } from "./components/tab/Tab.svelte"; +export { default as Tabs } from "./components/tabs/Tabs.svelte";