From d59a4c37382e4bf5bbca518c0843c06f4a4f9dec Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Thu, 15 Jan 2026 10:23:28 -0700 Subject: [PATCH 01/26] feat(icon): add chevron-expand and filter-lines icons Add two new icons for V2 table sort headers: - chevron-expand: up/down chevrons for sortable columns - filter-lines: three horizontal lines for filter buttons Includes test pages in React and Angular playgrounds at /features/v2-icons --- apps/prs/angular/src/app/app.component.html | 1 + apps/prs/angular/src/app/app.routes.ts | 2 ++ .../featV2Icons/feat-v2-icons.component.html | 28 ++++++++++++++++ .../featV2Icons/feat-v2-icons.component.ts | 10 ++++++ apps/prs/react/src/app/app.tsx | 1 + apps/prs/react/src/main.tsx | 2 ++ .../react/src/routes/features/featV2Icons.tsx | 33 +++++++++++++++++++ libs/common/src/lib/common.ts | 2 ++ .../src/components/icon/Icon.svelte | 4 +++ 9 files changed, 83 insertions(+) create mode 100644 apps/prs/angular/src/routes/features/featV2Icons/feat-v2-icons.component.html create mode 100644 apps/prs/angular/src/routes/features/featV2Icons/feat-v2-icons.component.ts create mode 100644 apps/prs/react/src/routes/features/featV2Icons.tsx diff --git a/apps/prs/angular/src/app/app.component.html b/apps/prs/angular/src/app/app.component.html index 23c184a0d1..5d7f493973 100644 --- a/apps/prs/angular/src/app/app.component.html +++ b/apps/prs/angular/src/app/app.component.html @@ -74,6 +74,7 @@ 3102 1908 2609 + v2 header icons diff --git a/apps/prs/angular/src/app/app.routes.ts b/apps/prs/angular/src/app/app.routes.ts index 4c79cc298b..55a377979c 100644 --- a/apps/prs/angular/src/app/app.routes.ts +++ b/apps/prs/angular/src/app/app.routes.ts @@ -57,6 +57,7 @@ import { Feat2829Component } from "../routes/features/feat2829/feat2829.componen import { Feat3102Component } from "../routes/features/feat3102/feat3102.component"; import { Feat1908Component } from "../routes/features/feat1908/feat1908.component"; import { Feat2609Component } from "../routes/features/feat2609/feat2609.component"; +import { FeatV2IconsComponent } from "../routes/features/featV2Icons/feat-v2-icons.component"; export const appRoutes: Route[] = [ { path: "everything", component: EverythingComponent }, @@ -117,4 +118,5 @@ export const appRoutes: Route[] = [ { path: "features/2829", component: Feat2829Component }, { path: "features/3102", component: Feat3102Component }, { path: "features/2609", component: Feat2609Component }, + { path: "features/v2-icons", component: FeatV2IconsComponent }, ]; diff --git a/apps/prs/angular/src/routes/features/featV2Icons/feat-v2-icons.component.html b/apps/prs/angular/src/routes/features/featV2Icons/feat-v2-icons.component.html new file mode 100644 index 0000000000..9c9fe9a8d6 --- /dev/null +++ b/apps/prs/angular/src/routes/features/featV2Icons/feat-v2-icons.component.html @@ -0,0 +1,28 @@ +
+ + V2: New Icons Test + Testing chevron-expand and filter-lines icons + + + + chevron-expand + + + + + + + filter-lines + + + + + + + + Expected appearance: + chevron-expand: Up and down chevrons (stacked) + filter-lines: Three horizontal lines, widest at top + + +
diff --git a/apps/prs/angular/src/routes/features/featV2Icons/feat-v2-icons.component.ts b/apps/prs/angular/src/routes/features/featV2Icons/feat-v2-icons.component.ts new file mode 100644 index 0000000000..dd90530af4 --- /dev/null +++ b/apps/prs/angular/src/routes/features/featV2Icons/feat-v2-icons.component.ts @@ -0,0 +1,10 @@ +import { Component } from "@angular/core"; +import { GoabBlock, GoabIcon, GoabText } from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-feat-v2-icons", + templateUrl: "./feat-v2-icons.component.html", + imports: [GoabBlock, GoabIcon, GoabText], +}) +export class FeatV2IconsComponent {} diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index 18bf1e7c81..e7244de0e4 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -76,6 +76,7 @@ export function App() { 2829 Modal ARIA Live Region 2877 Badge Types and Custom Icon 3102 MenuButton Width + v2 header icons A diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx index 4ab4406e3c..91c412415b 100644 --- a/apps/prs/react/src/main.tsx +++ b/apps/prs/react/src/main.tsx @@ -61,6 +61,7 @@ import { Feat2829Route } from "./routes/features/feat2829"; import { Feat2877Route } from "./routes/features/feat2877"; import Feat3102Route from "./routes/features/feat3102"; import { Feat2611Route } from "./routes/features/feat2611"; +import { FeatV2IconsRoute } from "./routes/features/featV2Icons"; const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); @@ -126,6 +127,7 @@ root.render( } /> } /> } /> + } /> diff --git a/apps/prs/react/src/routes/features/featV2Icons.tsx b/apps/prs/react/src/routes/features/featV2Icons.tsx new file mode 100644 index 0000000000..6c64bafc01 --- /dev/null +++ b/apps/prs/react/src/routes/features/featV2Icons.tsx @@ -0,0 +1,33 @@ +import { GoabBlock, GoabIcon, GoabText } from "@abgov/react-components"; +import type { JSX } from "react"; + +export function FeatV2IconsRoute(): JSX.Element { + return ( + + V2: New Icons Test + Testing chevron-expand and filter-lines icons + + + + chevron-expand + + + + + + + filter-lines + + + + + + + + Expected appearance: + chevron-expand: Up and down chevrons (stacked) + filter-lines: Three horizontal lines, widest at top + + + ); +} diff --git a/libs/common/src/lib/common.ts b/libs/common/src/lib/common.ts index af45326cad..014d50c81a 100644 --- a/libs/common/src/lib/common.ts +++ b/libs/common/src/lib/common.ts @@ -548,6 +548,7 @@ export type GoabIconBaseType = | "chevron-back" | "chevron-down-circle" | "chevron-down" + | "chevron-expand" | "chevron-forward-circle" | "chevron-forward" | "chevron-up-circle" @@ -616,6 +617,7 @@ export type GoabIconBaseType = | "filenames.ps1" | "film" | "filter-circle" + | "filter-lines" | "filter" | "finger-print" | "fish" diff --git a/libs/web-components/src/components/icon/Icon.svelte b/libs/web-components/src/components/icon/Icon.svelte index e0b1fd49ff..66b28514cb 100644 --- a/libs/web-components/src/components/icon/Icon.svelte +++ b/libs/web-components/src/components/icon/Icon.svelte @@ -125,6 +125,7 @@ | "chevron-back" | "chevron-down-circle" | "chevron-down" + | "chevron-expand" | "chevron-forward-circle" | "chevron-forward" | "chevron-up-circle" @@ -193,6 +194,7 @@ | "filenames.ps1" | "film" | "filter-circle" + | "filter-lines" | "filter" | "finger-print" | "fish" @@ -632,6 +634,7 @@ "chevron-down": ``, "chevron-forward": ``, "chevron-up": ``, + "chevron-expand": ``, "close-circle-filled": ``, "close-circle": ``, close: ``, @@ -649,6 +652,7 @@ "eye-off": ``, eye: ``, "filter-filled": ``, + "filter-lines": ``, filter: ``, "flag-filled": ``, flag: ``, From 6f66d8b233775177442358fc518c0c52c93992b7 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Mon, 3 Nov 2025 15:50:10 -0700 Subject: [PATCH 02/26] feat(#3151): update drawer to v2 --- .../src/components/drawer/Drawer.svelte | 280 +++++++++++++++--- .../src/components/drawer/drawer.spec.ts | 2 +- 2 files changed, 245 insertions(+), 37 deletions(-) diff --git a/libs/web-components/src/components/drawer/Drawer.svelte b/libs/web-components/src/components/drawer/Drawer.svelte index 37787554ac..6735036d51 100644 --- a/libs/web-components/src/components/drawer/Drawer.svelte +++ b/libs/web-components/src/components/drawer/Drawer.svelte @@ -9,7 +9,7 @@ import { fly } from "svelte/transition"; import noscroll from "../../common/no-scroll"; import { onDestroy, onMount, tick } from "svelte"; - import { dispatch, style, styles } from "../../common/utils"; + import { dispatch, style, styles, typeValidator } from "../../common/utils"; import { DrawerPosition, DrawerSize } from "../../common/types"; // ****** @@ -21,6 +21,12 @@ export let heading: string = ""; export let maxsize: DrawerSize = undefined; // is set based on the anchor value export let testid: string = "drawer"; + + // version + type VersionType = "1" | "2"; + const [Version, validateVersion] = typeValidator("Version", ["1", "2"]); + export let version: VersionType = "1"; + // ******* // Private // ******* @@ -31,7 +37,10 @@ // computes the required absolute position offset to hide the drawer when not shown let _drawerSize: number; + let _actionsHeight: number = 0; + let _headerHeight: number = 0; let _actionsSlotHasContent: boolean = false; + let _scrollableHeight: string = ""; let _scrollPos: "top" | "middle" | "bottom" | null = "top"; // to add the box-shadow to the drawer content // ======== @@ -53,6 +62,22 @@ } } + // Add reactive statement for height calculations + $: if (open && _contentEl) { + updateHeights(); + } + + // V2: Check initial scroll state when drawer opens + $: if (open && version === "2" && _contentEl) { + tick().then(() => { + const drawerContent = _contentEl?.querySelector('.drawer-content'); + if (drawerContent) { + const { scrollTop, scrollHeight, clientHeight } = drawerContent; + _scrollPos = calculateScrollPos(scrollTop, scrollHeight, clientHeight); + } + }); + } + $: { if (!open) { setTimeout(() => { @@ -67,6 +92,7 @@ onMount(async () => { await tick(); + validateVersion(version); if (position === "bottom") { _drawerSize = _contentEl?.getBoundingClientRect().height ?? 0; @@ -86,9 +112,21 @@ // Functions // ********* + // to set the scrollable height + function updateHeights() { + const headerEl = _contentEl?.querySelector(".header"); + const actionsEl = _contentEl?.querySelector(".drawer-actions"); + + _headerHeight = headerEl?.clientHeight ?? 0; + _actionsHeight = actionsEl?.clientHeight ?? 0; + _scrollableHeight = scrollableHeight(); + } + async function checkActionsSlotContent() { await tick(); _actionsSlotHasContent = !!$$slots.actions; + // Trigger height recalculation after checking slot content + updateHeights(); } function close(e: Event) { @@ -107,16 +145,30 @@ } }; - // handle scroll event to set the scroll position in order to add the box-shadow to the drawer content depending on the scroll position + function scrollableHeight() { + // V2: Let flex container handle sizing + if (version === "2") return "100%"; + + const edgeMargin = 16; // box shadow top and bottom + + // V1: Calculate available height by subtracting: + // - header height + // - actions height (if actions exist) + // - edge margins (for left/right) or drawer chrome (for bottom) + if (position === "bottom") { + return `calc(${maxsize} - ${_headerHeight}px - ${_actionsSlotHasContent ? _actionsHeight : 0}px)`; + } + return `calc(100vh - ${_headerHeight}px - ${_actionsSlotHasContent ? _actionsHeight : 0}px - ${edgeMargin}px)`; + } + + // V1: handle scroll event from goa-scrollable to set _scrollPos for shadows function handleScroll(e: CustomEvent) { const hasScroll = e.detail.scrollHeight > e.detail.offsetHeight; if (!open || !hasScroll) return; - // top if (e.detail.scrollTop == 0) { _scrollPos = "top"; } else if ( - // bottom Math.abs( e.detail.scrollHeight - e.detail.scrollTop - e.detail.offsetHeight, ) < 1 @@ -126,6 +178,23 @@ _scrollPos = "middle"; } } + + // V2: handle scroll event from drawer-content to set _scrollPos for borders + function handleV2Scroll(e: Event) { + if (!open) return; + const target = e.target as HTMLElement; + const { scrollTop, scrollHeight, clientHeight } = target; + _scrollPos = calculateScrollPos(scrollTop, scrollHeight, clientHeight); + } + + // Shared helper to calculate scroll position from scroll metrics + function calculateScrollPos(scrollTop: number, scrollHeight: number, clientHeight: number): "top" | "middle" | "bottom" | null { + const hasScroll = scrollHeight > clientHeight; + if (!hasScroll) return null; + if (scrollTop < 1) return "top"; + if (Math.abs(scrollHeight - scrollTop - clientHeight) < 1) return "bottom"; + return "middle"; + } @@ -146,14 +215,16 @@ use:noscroll={{ enable: open }} style={styles( style("--drawer-offset", `-${_drawerSize}px`), - style("max-height", position === "bottom" ? maxsize : "100vh"), - style("max-width", position === "bottom" ? "100%" : maxsize), - style("width", position === "bottom" ? "100%" : maxsize), + style("height", position === "bottom" ? "unset" : undefined), + style("max-width", position === "bottom" ? "unset" : (version === "2" ? `min(${maxsize}, calc(100vw - 2 * var(--goa-drawer-offset, 0)))` : `min(${maxsize}, 100vw)`)), + style("width", position === "bottom" ? "100%" : (version === "2" ? `min(${maxsize}, calc(100vw - 2 * var(--goa-drawer-offset, 0)))` : `min(${maxsize}, 100vw)`)), + style("max-height", position === "bottom" ? (version === "2" ? `min(${maxsize}, calc(100vh - 2 * var(--goa-drawer-offset, 0)))` : `min(${maxsize}, 100vh)`) : undefined), )} in:fly={_flyParams} out:fly={{ ..._flyParams, delay: 200 }} class:open={open} class:closing={!open} + class:v2={version === "2"} class={`drawer drawer-${position}`} class:drawer-open-bottom={position === "bottom" && open} class:drawer-open-right={position === "right" && open} @@ -169,7 +240,11 @@
{#if heading || $$slots.heading} {#if heading} - {heading} + {#if version === "2"} + {heading} + {:else} + {heading} + {/if} {:else} {/if} @@ -188,17 +263,27 @@
-
- +
+ {#if version === "1"} + +
+ +
+
+ {:else}
- + {/if}
@@ -242,6 +327,11 @@ inset 0 -8px 8px -8px rgba(0, 0, 0, 0.2); } + /* V2: Remove scroll shadows (sticky elements provide visual feedback) */ + .root .drawer.v2 .drawer-content { + box-shadow: none !important; + } + /* Background overlay */ .background { position: fixed; @@ -280,35 +370,82 @@ border-bottom: var(--goa-border-width-s) solid var(--goa-color-greyscale-200); display: flex; - padding: var(--goa-space-l) var(--goa-space-l) var(--goa-space-s) - var(--goa-space-l); + padding: var(--goa-space-l) var(--goa-space-l) var(--goa-space-s) var(--goa-space-l); + /* Padding: 24px top/right/left, 12px bottom */ justify-content: space-between; - align-items: center; + align-items: flex-start; /* Align to top instead of center */ + } + + /* V2: Header uses flexbox positioning (stays at top) */ + .v2.drawer-right .header, + .v2.drawer-left .header, + .v2.drawer-bottom .header { + flex: 0 0 auto; /* Don't grow or shrink */ + gap: var(--goa-space-2xs); /* 4px gap between heading and close icon */ + background-color: var(--goa-color-greyscale-white); + border-bottom: none; /* Remove border by default */ + } + + /* V2: Show header border when scrolled from top (middle or bottom position) */ + .root.middle .drawer.v2 .header, + .root.bottom .drawer.v2 .header { + border-bottom: var(--goa-border-width-s) solid var(--goa-color-greyscale-200); } /* Content styles */ .drawer-content { box-shadow: none; - flex: 1 1 auto; - min-height: 0; + flex: 0 1 auto; /* Don't grow, but can shrink - keeps actions below content */ + min-height: 0; /* Allow flexbox to shrink this element */ + overflow: hidden; /* Contain the scrollable content */ } - .drawer-content goa-scrollable { - height: 100%; + /* V2: drawer-content scrolls and takes remaining space */ + .v2.drawer-right .drawer-content, + .v2.drawer-left .drawer-content, + .v2.drawer-bottom .drawer-content { + flex: 1 1 auto; /* Take remaining space */ + overflow-y: auto; /* V2 scrolls here, not via goa-scrollable */ } .scroll-content { - padding: var(--goa-space-l) var(--goa-space-xl); + padding: var(--goa-drawer-content-padding-vertical, var(--goa-space-l)) var(--goa-drawer-content-padding-horizontal, var(--goa-space-xl)); + } + + /* Remove margin-top from first child in content to prevent double spacing */ + .scroll-content > :first-child { + margin-top: 0; } /* Actions styles */ .drawer-actions { width: 100%; - padding: var(--goa-space-l) var(--goa-space-xl) var(--goa-space-xl); + padding: var(--goa-drawer-actions-padding-top, var(--goa-space-l)) var(--goa-drawer-content-padding-horizontal, var(--goa-space-xl)) var(--goa-drawer-actions-padding-bottom, var(--goa-space-xl)); border-top: var(--goa-border-width-s) solid var(--goa-color-greyscale-200); background: var(--goa-color-greyscale-white); } + /* V2: Actions use flexbox positioning (stay at bottom) */ + .v2.drawer-right .drawer-actions, + .v2.drawer-left .drawer-actions, + .v2.drawer-bottom .drawer-actions { + flex: 0 0 auto; /* Don't grow or shrink */ + background-color: var(--goa-color-greyscale-white); + border-top: none; /* Remove border by default */ + } + + /* V2: Show actions border when has overflow AND not at bottom (top or middle position) */ + .root.top .drawer.v2 .drawer-actions, + .root.middle .drawer.v2 .drawer-actions { + border-top: var(--goa-border-width-s) solid var(--goa-color-greyscale-200); + } + + /* V2: Bottom drawer actions rounded corners */ + .v2.drawer-bottom .drawer-actions { + border-bottom-left-radius: var(--goa-drawer-border-radius, 24px); + border-bottom-right-radius: var(--goa-drawer-border-radius, 24px); + } + .drawer-actions.empty-actions { padding: 0; border-top: none; @@ -319,46 +456,117 @@ margin-top: var(--goa-space-2xs); } - /* Position-specific styles */ + /* Bottom */ + .drawer-bottom { bottom: var(--drawer-offset); width: 100%; - min-height: 300px; - border-top-left-radius: 0.5rem; - border-top-right-radius: 0.5rem; + height: 300px; + border-top-left-radius: var(--goa-drawer-border-radius, 0.5rem); + border-top-right-radius: var(--goa-drawer-border-radius, 0.5rem); transform: translateY(100%); box-shadow: var(--goa-drawer-bottom-shadow); } - .drawer-bottom .drawer-content { - overflow-y: auto; - } - .drawer-open-bottom { bottom: 0; } + /* V2: Border radius + 16px offset from edges */ + .drawer-bottom.v2 { + left: var(--goa-drawer-offset, 0); + right: var(--goa-drawer-offset, 0); + width: auto !important; /* Override base width: 100% */ + height: auto; + overflow-y: hidden; /* No scroll on drawer itself */ + border-radius: var(--goa-drawer-border-radius, 24px); /* All corners 24px */ + box-shadow: var(--goa-drawer-shadow); + transform: translateY(100%); /* Start off-screen at bottom */ + transition: transform 0.2s ease-out; + } + .drawer-bottom.v2.open, + .drawer-bottom.v2.drawer-open-bottom { + bottom: var(--goa-drawer-offset, 0); + transform: translateY(0); /* Slide in */ + } + /* Right */ .drawer-right { right: var(--drawer-offset); - height: 100%; + height: auto; /* Content-driven height */ + min-height: 100vh; /* V1: Full height background */ transform: translateX(100%); box-shadow: var(--goa-drawer-right-shadow); } + .drawer-open-right { right: 0; } + /* V2: Border radius + 16px offset from edges + content-driven height */ + .v2.drawer-right { + right: var(--drawer-offset); + top: var(--goa-drawer-offset, 0); + /* No bottom positioning - allows height: auto to work naturally */ + height: auto; + min-height: 0; /* Override V1 min-height */ + /* Max-height accounts for BOTH top and bottom margins (modal stays floating) */ + max-height: calc(100vh - 2 * var(--goa-drawer-offset, 0)); + overflow-y: hidden; /* No scroll on drawer itself */ + border-radius: var(--goa-drawer-border-radius, 24px); + box-shadow: var(--goa-drawer-shadow); + transform: translateX(100%); /* Start off-screen to the right */ + /* Smooth transitions for position and transform */ + transition: bottom 0.15s ease-out, transform 0.2s ease-out; + } + .v2.drawer-open-right { + right: var(--goa-drawer-offset, 0); + transform: translateX(0); /* Slide in */ + } + + /* V2: When scrolled to bottom, add bottom constraint to lift drawer up */ + .root.bottom .v2.drawer-right { + bottom: var(--goa-drawer-offset, 0); + } + /* Left */ .drawer-left { left: var(--drawer-offset); - height: 100%; - box-shadow: var(--goa-drawer-left-shadow); + height: auto; /* Content-driven height */ + min-height: 100vh; /* V1: Full height background */ transform: translateX(-100%); + box-shadow: var(--goa-drawer-left-shadow); } + .drawer-open-left { left: 0; } + + /* V2: Border radius + 16px offset from edges + content-driven height */ + .v2.drawer-left { + left: var(--drawer-offset); + top: var(--goa-drawer-offset, 0); + /* No bottom positioning - allows height: auto to work naturally */ + height: auto; + min-height: 0; /* Override V1 min-height */ + /* Max-height accounts for BOTH top and bottom margins (modal stays floating) */ + max-height: calc(100vh - 2 * var(--goa-drawer-offset, 0)); + overflow-y: hidden; /* No scroll on drawer itself */ + border-radius: var(--goa-drawer-border-radius, 24px); + box-shadow: var(--goa-drawer-shadow); + transform: translateX(-100%); /* Start off-screen to the left */ + /* Smooth transitions for position and transform */ + transition: bottom 0.15s ease-out, transform 0.2s ease-out; + } + .v2.drawer-open-left { + left: var(--goa-drawer-offset, 0); + transform: translateX(0); /* Slide in */ + } + + /* V2: When scrolled to bottom, add bottom constraint to lift drawer up */ + .root.bottom .v2.drawer-left { + bottom: var(--goa-drawer-offset, 0); + } diff --git a/libs/web-components/src/components/drawer/drawer.spec.ts b/libs/web-components/src/components/drawer/drawer.spec.ts index debc12e833..ec25e6d085 100644 --- a/libs/web-components/src/components/drawer/drawer.spec.ts +++ b/libs/web-components/src/components/drawer/drawer.spec.ts @@ -120,7 +120,7 @@ describe("Drawer", () => { const drawerEl = await el.findByTestId("drawer"); await waitFor(() => { const drawer = drawerEl.querySelector(".drawer") as HTMLElement; - expect(drawer?.style.maxWidth).toBe(maxsize); + expect(drawer?.getAttribute("style") ?? "").toContain(maxsize); }); }); From e83c585d0d5d36abd318e42bd6435c7dc760d206 Mon Sep 17 00:00:00 2001 From: Benji Franck Date: Tue, 13 Jan 2026 09:20:10 -0700 Subject: [PATCH 03/26] feat(#3214): update footer to v2 --- .../FooterMetaSection.svelte | 2 +- .../FooterNavSection.svelte | 5 +- .../src/components/footer/Footer.svelte | 71 +++++++++++++------ 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/libs/web-components/src/components/footer-meta-section/FooterMetaSection.svelte b/libs/web-components/src/components/footer-meta-section/FooterMetaSection.svelte index d7177d0bac..ed0f9e658f 100644 --- a/libs/web-components/src/components/footer-meta-section/FooterMetaSection.svelte +++ b/libs/web-components/src/components/footer-meta-section/FooterMetaSection.svelte @@ -60,7 +60,7 @@ flex-wrap: wrap; gap: var(--goa-footer-meta-links-gap); padding: 0; - margin: 8px 0px 0px 0px; + margin: var(--goa-footer-meta-links-margin, 8px 0px 0px 0px); list-style: none; } diff --git a/libs/web-components/src/components/footer-nav-section/FooterNavSection.svelte b/libs/web-components/src/components/footer-nav-section/FooterNavSection.svelte index f63f95ff99..63b67888dc 100644 --- a/libs/web-components/src/components/footer-nav-section/FooterNavSection.svelte +++ b/libs/web-components/src/components/footer-nav-section/FooterNavSection.svelte @@ -81,7 +81,10 @@ list-style-type: none; padding-left: 0; margin: 0; - gap: 12px; /* spacing between links on mobile */ + } + + li:not(:last-child) { + margin-bottom: var(--goa-space-s); } @media not (--mobile) { diff --git a/libs/web-components/src/components/footer/Footer.svelte b/libs/web-components/src/components/footer/Footer.svelte index a56737fdee..fef3d4c709 100644 --- a/libs/web-components/src/components/footer/Footer.svelte +++ b/libs/web-components/src/components/footer/Footer.svelte @@ -7,6 +7,7 @@ export let maxcontentwidth: string = ""; export let testid: string = ""; export let url: string = "https://alberta.ca"; + export let version: "1" | "2" = "1"; let rootEl: HTMLElement; let navLinks: Element[]; @@ -28,6 +29,7 @@
@@ -97,7 +112,7 @@ font-size: var(--goa-footer-typography-small-screen); } .logo { - width: var(--goa-footer-size-logo-mobile); + width: var(--goa-footer-size-logo-mobile); } } @@ -106,7 +121,7 @@ padding: var(--goa-footer-padding-medium-screen); } .logo { - width: var(--goa-footer-size-logo-tablet); + width: var(--goa-footer-size-logo-tablet); } } @@ -115,7 +130,7 @@ padding: var(--goa-footer-padding-large-screen); } .logo { - width: var(--goa-footer-size-logo-desktop); + width: var(--goa-footer-size-logo-desktop); } } @@ -126,7 +141,7 @@ } .meta-section.with-meta-links { - /* gap between meta links and goa log when stacked vertically on small screen */ + /* gap between meta links and goa log when stacked vertically on small screen */ justify-content: space-between; } @@ -143,13 +158,16 @@ gap: var(--goa-space-xl); /* space between different columns/rows of nav links on mobile */ } - .abgov { display: flex; flex-direction: column; justify-content: space-between; width: 100%; - gap: var(--goa-space-m); /* gap between copyright and goa log when stacked vertically on small screen */ + gap: var(--goa-space-m); /* gap between copyright and goa log when stacked vertically on small screen */ + } + + .v2 .abgov { + justify-content: flex-end; } @container self (--not-mobile) { @@ -172,6 +190,15 @@ .abgov.with-meta-links { align-items: flex-end; } + + .v2 .abgov { + flex-direction: row; + } + + .v2 .abgov.with-meta-links { + flex-direction: row; + } + } .abgov.with-meta-links { @@ -196,6 +223,6 @@ a:focus-visible { outline: var(--goa-footer-link-focus); - border-radius: 2px; + border-radius: var(--goa-footer-link-focus-border-radius); } From 034314d273b07efe494832546bf85797b328a4b2 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Mon, 1 Dec 2025 14:27:14 -0700 Subject: [PATCH 04/26] fix(#3232): GoabText tag prop fix to correctly apply corresponding heading size --- apps/prs/react/src/app/app.tsx | 1 + apps/prs/react/src/main.tsx | 2 + apps/prs/react/src/routes/bugs/bug3232.tsx | 163 ++++++++++++++++++ .../src/components/text/Text.svelte | 40 +++-- 4 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 apps/prs/react/src/routes/bugs/bug3232.tsx diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index e7244de0e4..bde61a446d 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -53,6 +53,7 @@ export function App() { 3118 Text Component ID 3201 Input Component Events 3215 Drawer Initial Height + 3232 GoabText Tag Size 3248 Dropdown Dynamic Children Sync 3322 App Header Menu Hover
diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx index 91c412415b..2230d9e4db 100644 --- a/apps/prs/react/src/main.tsx +++ b/apps/prs/react/src/main.tsx @@ -37,6 +37,7 @@ import { Bug2977Route } from "./routes/bugs/bug2977"; import { Bug3118Route } from "./routes/bugs/bug3118"; import { Bug3201Route } from "./routes/bugs/bug3201"; import { Bug3215Route } from "./routes/bugs/bug3215"; +import { Bug3232Route } from "./routes/bugs/bug3232"; import { Bug3248Route } from "./routes/bugs/bug3248"; import { Bug3322Route } from "./routes/bugs/bug3322"; @@ -105,6 +106,7 @@ root.render( } /> } /> } /> + } /> } /> } /> diff --git a/apps/prs/react/src/routes/bugs/bug3232.tsx b/apps/prs/react/src/routes/bugs/bug3232.tsx new file mode 100644 index 0000000000..bfcb4ce147 --- /dev/null +++ b/apps/prs/react/src/routes/bugs/bug3232.tsx @@ -0,0 +1,163 @@ +import { + GoabBlock, + GoabText, + GoabDivider, + GoabDetails, + GoabLink, +} from "@abgov/react-components"; + +export function Bug3232Route() { + return ( +
+ + Bug #3232: GoabText tag prop should auto-apply heading size + + + + + + View on GitHub + + + + + + When using GoabText with a heading tag (h1-h5) but without explicitly setting + the size prop, the component renders the semantic HTML element but doesn't + apply the design system typography styles. Instead, it falls back to browser + defaults, which don't match our design tokens. + + + + + + + Test Cases + + + Left column uses just tag. Center column uses tag + explicit size. + Right column uses just explicit size. They should all match. + + + Test 1: H1 → heading-xl +
+
+ Heading XL + tag="h1" +
+
+ Heading XL + tag="h1" size="heading-xl" +
+
+ Heading XL + size="heading-xl" +
+
+ + Test 2: H2 → heading-l +
+
+ Heading L + tag="h2" +
+
+ Heading L + tag="h2" size="heading-l" +
+
+ Heading L + size="heading-l" +
+
+ + Test 3: H3 → heading-m +
+
+ Heading M + tag="h3" +
+
+ Heading M + tag="h3" size="heading-m" +
+
+ Heading M + size="heading-m" +
+
+ + Test 4: H4 → heading-s +
+
+ Heading S + tag="h4" +
+
+ Heading S + tag="h4" size="heading-s" +
+
+ Heading S + size="heading-s" +
+
+ + Test 5: H5 → heading-xs +
+
+ Heading XS + tag="h5" +
+
+ Heading XS + tag="h5" size="heading-xs" +
+
+ Heading XS + size="heading-xs" +
+
+ + + + Test 6: Non-heading tags (no auto-size) + + These should remain unstyled unless size is explicitly set. + + +
+
+ Paragraph with no size - should be unstyled + tag="p" (no size) +
+
+ Paragraph with body-m size + tag="p" size="body-m" +
+
+ Span with no size - should be unstyled + tag="span" (no size) +
+
+ + + + Test 7: Explicit size overrides tag default + + Setting size explicitly should override the tag-based default. + + +
+
+ H1 with body-s size (explicit override) + tag="h1" size="body-s" +
+
+ H2 with heading-xs size (explicit override) + tag="h2" size="heading-xs" +
+
+
+ ); +} diff --git a/libs/web-components/src/components/text/Text.svelte b/libs/web-components/src/components/text/Text.svelte index 6b51299936..ca438f7c3b 100644 --- a/libs/web-components/src/components/text/Text.svelte +++ b/libs/web-components/src/components/text/Text.svelte @@ -1,11 +1,4 @@ - + + +
+
(_open = toBoolean(`${target?.open}`))} + > + + + {heading} + + +
+ +
+
+
+ + diff --git a/libs/web-components/src/components/work-side-menu/WorkSideMenuItem.svelte b/libs/web-components/src/components/work-side-menu/WorkSideMenuItem.svelte index 627a29ad3b..aff03e5425 100644 --- a/libs/web-components/src/components/work-side-menu/WorkSideMenuItem.svelte +++ b/libs/web-components/src/components/work-side-menu/WorkSideMenuItem.svelte @@ -60,6 +60,14 @@ function handleUpdateItem(e: CustomEvent) { let currentLink = e.detail.current; current = _linkEl === currentLink; + if (current) { + dispatch( + _rootEl, + "_itemCurrent", + { el: _linkEl, label: label }, + { bubbles: true }, + ); + } } function handleMouseEnter() { @@ -72,17 +80,11 @@ } function addEventListeners() { - _linkEl.addEventListener( - "_update", - handleUpdateItem as EventListener, - ); + _linkEl.addEventListener("_update", handleUpdateItem as EventListener); } function removeEventListeners() { - _linkEl.removeEventListener( - "_update", - handleUpdateItem as EventListener, - ); + _linkEl.removeEventListener("_update", handleUpdateItem as EventListener); } @@ -113,6 +115,11 @@ + {#if $$slots.trailingContent} +
+ +
+ {/if} {#if badge}
Date: Thu, 29 Jan 2026 19:22:09 -0700 Subject: [PATCH 08/26] chore: use hard coded date to prevent Februrary failures --- .../src/components/calendar/calendar.spec.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/libs/web-components/src/components/calendar/calendar.spec.ts b/libs/web-components/src/components/calendar/calendar.spec.ts index 4f716ba837..0603a641f9 100644 --- a/libs/web-components/src/components/calendar/calendar.spec.ts +++ b/libs/web-components/src/components/calendar/calendar.spec.ts @@ -153,7 +153,14 @@ it("emits an event when a date is selected", async () => { }); it("updates the calendar when a new month is selected", async () => { - const { container, queryByTestId } = render(Calendar); + const year = 2026; + const month = 4; + const day = 1; + const date = new Date(year, month, day); + + const { container, queryByTestId } = render(Calendar, { + value: `${year}-0${month}-${day}`, + }); await tick(); const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; @@ -161,8 +168,6 @@ it("updates the calendar when a new month is selected", async () => { // 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="${getDateStamp(date)}"]`, @@ -173,17 +178,14 @@ it("updates the calendar when a new month is selected", async () => { } // change month - const otherMonth = ((new Date().getMonth() + 1) % 12) + 1; // +1 since getMonth is zero based we need some +1s monthsEl?.dispatchEvent( new CustomEvent("_change", { - detail: { value: otherMonth }, + detail: { value: month + 1 }, }), ); await waitFor(() => { - const date = toDayStart(new Date()); - date.setMonth(otherMonth - 1); // revert to 0-index value - date.setDate(1); + const date = new Date(year, month + 1, day); const dayOfWeek = date.getDay(); const buttonEl = queryByTestId(getDateStamp(date)); From 1489a7d61681be83f19d604258271a68d22a9cb4 Mon Sep 17 00:00:00 2001 From: Chris Olsen Date: Wed, 28 Jan 2026 15:09:21 -0700 Subject: [PATCH 09/26] chore: add lsp setup instructions to README --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index e5d744a892..31d6fa3040 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,17 @@ is designed to be used to help bring consistency to all Government of Alberta websites and web applications. It's also being designed to help ease the burden on designers and developers alike throughout the development process. +### Developer setup + +LSP tools +``` +npm i -g typescript-language-server \ + svelte-language-server \ + prettier \ + vscode-css-languageservice \ + vscode-html-languageservice +``` + ### Playground setup Run the `dev-setup` file. From 54676bf4a9cd46861a73687a044d186e234c82d1 Mon Sep 17 00:00:00 2001 From: Chris Olsen Date: Thu, 29 Jan 2026 15:01:39 -0700 Subject: [PATCH 10/26] chore: allow tests to be run without a watch --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index 49c2eca588..374e137afb 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,11 @@ "build:vscode-doc": "node libs/web-components/custom-element-manifest-analyze.js", "pretest:pr": "npx nx run common:build && npx nx run web-components:build && npx nx run react-components:build", "test:pr": "vitest --run --project=*-unit && vitest --run --project=*-headless && nx test angular-components", + "test:unit": "vitest --run --project=*-unit", "test:unit:watch": "vitest --project=*-unit", + "test:browser": "vitest --run --project=*-browser", "test:browser:watch": "vitest --project=*-browser", + "test:headless": "vitest --run --project=*-headless", "test:headless:watch": "vitest --project=*-headless", "test:angular": "nx test angular-components", "lint": "nx run-many --target=lint --exclude=angular --exclude=react --exclude=web", From c28d19d7c03465fa747377c8cdd93d37715001e4 Mon Sep 17 00:00:00 2001 From: Chris Olsen Date: Thu, 29 Jan 2026 15:04:21 -0700 Subject: [PATCH 11/26] feat(#3306): add ability to manually set the hash slug for tabs --- apps/prs/angular/src/app/app.component.html | 1 + apps/prs/angular/src/app/app.routes.ts | 2 + .../features/feat3306/feat3306.component.html | 111 ++++++ .../features/feat3306/feat3306.component.ts | 33 ++ apps/prs/react/src/app/app.tsx | 1 + apps/prs/react/src/main.tsx | 2 + .../react/src/routes/features/feat3306.tsx | 167 ++++++++ .../src/lib/components/tab/tab.spec.ts | 44 --- .../src/lib/components/tab/tab.ts | 29 +- .../src/lib/components/tabs/tabs.spec.ts | 35 +- .../specs/tabs.browser.spec.tsx | 360 +++++++++++------- .../react-components/src/lib/tab/tab.spec.tsx | 53 --- libs/react-components/src/lib/tab/tab.tsx | 12 +- .../src/lib/tabs/tabs.spec.tsx | 44 +-- .../src/components/tab/Tab.svelte | 7 +- .../src/components/tab/tab.spec.ts | 12 +- .../src/components/tabs/Tabs.svelte | 20 +- .../tabs/TabsWrapperWithSlug.test.svelte | 16 + .../src/components/tabs/tabs.spec.ts | 37 ++ 19 files changed, 707 insertions(+), 279 deletions(-) create mode 100644 apps/prs/angular/src/routes/features/feat3306/feat3306.component.html create mode 100644 apps/prs/angular/src/routes/features/feat3306/feat3306.component.ts create mode 100644 apps/prs/react/src/routes/features/feat3306.tsx delete mode 100644 libs/angular-components/src/lib/components/tab/tab.spec.ts delete mode 100644 libs/react-components/src/lib/tab/tab.spec.tsx create mode 100644 libs/web-components/src/components/tabs/TabsWrapperWithSlug.test.svelte diff --git a/apps/prs/angular/src/app/app.component.html b/apps/prs/angular/src/app/app.component.html index a4b2eca761..67e1c6564c 100644 --- a/apps/prs/angular/src/app/app.component.html +++ b/apps/prs/angular/src/app/app.component.html @@ -72,6 +72,7 @@ 2730 2829 3102 + 3306 1908 2609 v2 header icons diff --git a/apps/prs/angular/src/app/app.routes.ts b/apps/prs/angular/src/app/app.routes.ts index 272576adce..e2ec71a00f 100644 --- a/apps/prs/angular/src/app/app.routes.ts +++ b/apps/prs/angular/src/app/app.routes.ts @@ -59,6 +59,7 @@ import { Feat1908Component } from "../routes/features/feat1908/feat1908.componen import { Feat2609Component } from "../routes/features/feat2609/feat2609.component"; import { FeatV2IconsComponent } from "../routes/features/featV2Icons/feat-v2-icons.component"; import { Feat3137Component } from "../routes/features/feat3137/feat3137.component"; +import { Feat3306Component } from "../routes/features/feat3306/feat3306.component"; export const appRoutes: Route[] = [ { path: "everything", component: EverythingComponent }, @@ -122,4 +123,5 @@ export const appRoutes: Route[] = [ { path: "features/v2-icons", component: FeatV2IconsComponent }, { path: "features/3137", component: Feat3137Component }, { path: "features/1908", component: Feat1908Component }, + { path: "features/3306", component: Feat3306Component }, ]; diff --git a/apps/prs/angular/src/routes/features/feat3306/feat3306.component.html b/apps/prs/angular/src/routes/features/feat3306/feat3306.component.html new file mode 100644 index 0000000000..74ebd0d62f --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3306/feat3306.component.html @@ -0,0 +1,111 @@ + + + + 3306 + - Feature 3306 + + + Add ability to set the `slug` for a Tab component. This slug value will work for + tabs that use a string `heading` value as well as when a `heading` slot is + defined. + + + + + + + + + + + + Status + Text + Number + Action + + + + + + + + Lorem Ipsum + 1234567890 + + Action + + + + + + + Lorem Ipsum + 1234567890 + + Action + + + + + + +
+ Review pending +
+ There should be a slug here + + + + Status + Text + Number + Action + + + + + + + + Lorem Ipsum + 1234567890 + + Action + + + + +
+ +
+ Complete +
+ + + + Status + Text + Number + Action + + + + + + + + Lorem Ipsum + 1234567890 + + Action + + + + +
+
+ +
diff --git a/apps/prs/angular/src/routes/features/feat3306/feat3306.component.ts b/apps/prs/angular/src/routes/features/feat3306/feat3306.component.ts new file mode 100644 index 0000000000..612b09518f --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3306/feat3306.component.ts @@ -0,0 +1,33 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { + GoabBlock, + GoabDivider, + GoabText, + GoabTabs, + GoabTab, + GoabTable, + GoabBadge, + GoabButton, +} from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-feat3306", + templateUrl: "./feat3306.component.html", + imports: [ + CommonModule, + GoabBlock, + GoabText, + GoabDivider, + GoabTabs, + GoabTab, + GoabTable, + GoabBadge, + GoabButton, + ], +}) +export class Feat3306Component { + review = [0, 1, 2, 3]; + complete = [0, 1]; +} diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index 83e50adab8..d18755fbfa 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -79,6 +79,7 @@ export function App() { 3102 MenuButton Width v2 header icons 3137 Work Side Menu Group + 3306 Custom slug value for tabs A diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx index 278c19e6b9..47587f8539 100644 --- a/apps/prs/react/src/main.tsx +++ b/apps/prs/react/src/main.tsx @@ -64,6 +64,7 @@ import Feat3102Route from "./routes/features/feat3102"; import { Feat2611Route } from "./routes/features/feat2611"; import { FeatV2IconsRoute } from "./routes/features/featV2Icons"; import { Feat3137Route } from "./routes/features/feat3137"; +import Feat3306Route from "./routes/features/feat3306"; const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); @@ -133,6 +134,7 @@ root.render( } /> } /> } /> + } /> diff --git a/apps/prs/react/src/routes/features/feat3306.tsx b/apps/prs/react/src/routes/features/feat3306.tsx new file mode 100644 index 0000000000..c4f589f190 --- /dev/null +++ b/apps/prs/react/src/routes/features/feat3306.tsx @@ -0,0 +1,167 @@ +import { + GoabBlock, + GoabDivider, + GoabText, + GoabTabs, + GoabTab, + GoabTable, + GoabBadge, + GoabButton, +} from "@abgov/react-components"; + +export default function Feat3306Component() { + const review = [0, 1, 2, 3]; + const complete = [0, 1]; + + return ( + + + + + 3306 + {" "} + + + Add ability to set the `slug` for a Tab component. This slug value will work for + tabs that use a string `heading` value as well as when a `heading` slot is + defined. + + + + + + {/* Add feature implementation here */} + + + + + + Status + Text + Number + Action + + + + {review.map((i) => ( + + + + + Lorem Ipsum + 1234567890 + + Action + + + ))} + {complete.map((i) => ( + + + + + Lorem Ipsum + 1234567890 + + Action + + + ))} + + + + + Review pending + + } + > + There should be a slug here + + + + Status + Text + Number + Action + + + + {review.map((i) => ( + + + + + Lorem Ipsum + 1234567890 + + Action + + + ))} + + + + + Complete + + } + > + + + + Status + Text + Number + Action + + + + {complete.map((i) => ( + + + + + Lorem Ipsum + 1234567890 + + Action + + + ))} + + + + + + + + Test: Two Tab Sets on Same Page + + Second tab set with initialTab=2 and different slugs. + + + + + Content for Dashboard tab (second set) + + + + Content for Settings tab (second set) - This should be initially selected + + + + Content for Profile tab (second set) + + + + ); +} diff --git a/libs/angular-components/src/lib/components/tab/tab.spec.ts b/libs/angular-components/src/lib/components/tab/tab.spec.ts deleted file mode 100644 index 03749bbbc9..0000000000 --- a/libs/angular-components/src/lib/components/tab/tab.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; -import { GoabTab } from "./tab"; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; - -@Component({ - standalone: true, - imports: [GoabTab], - template: ` - -

- Profile: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. -

-
- `, -}) -class TestTabComponent { - /** do nothing **/ -} - -describe("GoABTab", () => { - let fixture: ComponentFixture; - - beforeEach(fakeAsync(() => { - TestBed.configureTestingModule({ - imports: [GoabTab, TestTabComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - }).compileComponents(); - - fixture = TestBed.createComponent(TestTabComponent); - fixture.detectChanges(); - tick(); - fixture.detectChanges(); - })); - - it("should render", () => { - const el = fixture.nativeElement.querySelector("goa-tab"); - expect(el?.innerHTML).toContain("Profile"); - const content = el?.querySelector("p"); - expect(content?.textContent).toContain( - "Profile: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ", - ); - }); -}); diff --git a/libs/angular-components/src/lib/components/tab/tab.ts b/libs/angular-components/src/lib/components/tab/tab.ts index 37c982ceaa..1221035211 100644 --- a/libs/angular-components/src/lib/components/tab/tab.ts +++ b/libs/angular-components/src/lib/components/tab/tab.ts @@ -1,25 +1,40 @@ -import { CUSTOM_ELEMENTS_SCHEMA, Component, Input, TemplateRef, OnInit, ChangeDetectorRef, booleanAttribute } from "@angular/core"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + TemplateRef, + OnInit, + ChangeDetectorRef, + booleanAttribute, +} from "@angular/core"; import { NgTemplateOutlet, CommonModule } from "@angular/common"; @Component({ standalone: true, selector: "goab-tab", template: ` - + -
- - {{getHeadingAsString()}} -
+ @if (typeof heading !== "string") { +
+ +
+ }
`, schemas: [CUSTOM_ELEMENTS_SCHEMA], - imports: [NgTemplateOutlet, CommonModule] + imports: [NgTemplateOutlet, CommonModule], }) export class GoabTab implements OnInit { isReady = false; @Input() heading!: string | TemplateRef; @Input({ transform: booleanAttribute }) disabled?: boolean; + @Input() slug?: string; constructor(private cdr: ChangeDetectorRef) {} diff --git a/libs/angular-components/src/lib/components/tabs/tabs.spec.ts b/libs/angular-components/src/lib/components/tabs/tabs.spec.ts index 4020b66a51..fd4dc2c428 100644 --- a/libs/angular-components/src/lib/components/tabs/tabs.spec.ts +++ b/libs/angular-components/src/lib/components/tabs/tabs.spec.ts @@ -10,7 +10,7 @@ import { fireEvent } from "@testing-library/dom"; imports: [GoabTabs, GoabTab], template: ` - Tab content + Tab content `, }) @@ -21,6 +21,20 @@ class TestTabsComponent { } } +@Component({ + standalone: true, + imports: [GoabTabs, GoabTab], + template: ` + + Overview content + Details content + + `, +}) +class TestTabsWithSlugComponent { + /** do nothing **/ +} + describe("GoABTabs", () => { let fixture: ComponentFixture; let component: TestTabsComponent; @@ -40,9 +54,10 @@ describe("GoABTabs", () => { it("should render", () => { const el = fixture.nativeElement.querySelector("goa-tabs"); + expect(el?.getAttribute("initialtab")).toBe("1"); expect(el?.getAttribute("testid")).toBe("foo"); - expect(el?.querySelector("goa-tab")?.innerHTML).toContain("Profile"); + expect(el?.innerHTML).toContain("Profile"); expect(el?.textContent).toContain("Tab content"); }); @@ -59,4 +74,20 @@ describe("GoABTabs", () => { expect(onChange).toHaveBeenCalledWith({ tab: 2 }); }); + + it("should render tabs with slug props", fakeAsync(() => { + const slugFixture = TestBed.createComponent(TestTabsWithSlugComponent); + slugFixture.detectChanges(); + tick(); + slugFixture.detectChanges(); + + const tabElements = slugFixture.nativeElement.querySelectorAll("goa-tab"); + expect(tabElements.length).toBe(2); + + // First tab should have slug attribute + expect(tabElements[0].getAttribute("slug")).toBe("overview-section"); + + // Second tab should not have slug attribute + expect(tabElements[1].getAttribute("slug")).toBeNull(); + })); }); diff --git a/libs/react-components/specs/tabs.browser.spec.tsx b/libs/react-components/specs/tabs.browser.spec.tsx index e687da6f73..a35d23acd9 100644 --- a/libs/react-components/specs/tabs.browser.spec.tsx +++ b/libs/react-components/specs/tabs.browser.spec.tsx @@ -2,6 +2,7 @@ import { render } from "vitest-browser-react"; import { GoabTabs, GoabTab } from "../src"; import { expect, describe, it, vi } from "vitest"; +import { GoabBadge } from "../src/lib/badge/badge"; describe("Tabs Browser Tests", () => { describe("bug-2433", () => { @@ -87,20 +88,21 @@ describe("Tabs Browser Tests", () => { ); }; - const { getByTestId } = render(); + const { getByRole } = render(); + const tablist = getByRole("tab"); - // Wait for component to be fully rendered + // wait for component to be fully rendered await vi.waitFor(() => { - expect(getByTestId("test-tabs")).toBeTruthy(); + expect(tablist.elements().length).toBe(2); }); // Click on Tab 2 - const tab2 = getByTestId("tab-2"); + const tab2 = tablist.elements()[1]; await tab2.click(); // Verify we're on tab 2 expect(window.location.pathname).toBe(tabsPage); - expect(window.location.hash).toBe("#tab-1"); + expect(window.location.hash).toBe("#tab-2"); // Go back in history window.history.back(); @@ -183,50 +185,44 @@ describe("Tabs Browser Tests", () => { // Get tab elements directly const getTabs = () => document.querySelectorAll("goa-tab"); - let goaTabs = getTabs(); + const goaTabs = getTabs(); expect(goaTabs.length).toBe(3); - // WHEN - Change hash to #tab-1 (should activate "Tab 2" - second tab, zero-indexed) window.location.hash = "#tab-1"; window.dispatchEvent(new Event("hashchange")); - // THEN - Tab 2 should be open (index 1) await vi.waitFor( () => { - goaTabs = getTabs(); - expect(goaTabs[1].getAttribute("open")).toBe("true"); - expect(goaTabs[0].getAttribute("open")).toBe("false"); + const goaTabs = getTabs(); + expect(goaTabs[0].getAttribute("open")).toBe("true"); + expect(goaTabs[1].getAttribute("open")).toBe("false"); expect(goaTabs[2].getAttribute("open")).toBe("false"); }, { timeout: 2000 }, ); - // WHEN - Change hash to #tab-2 (should activate "Tab 3" - third tab, zero-indexed) window.location.hash = "#tab-2"; window.dispatchEvent(new Event("hashchange")); - // THEN - Tab 3 should be open (index 2) await vi.waitFor( () => { - goaTabs = getTabs(); - expect(goaTabs[2].getAttribute("open")).toBe("true"); // "Tab 3" at index 2 + const goaTabs = getTabs(); expect(goaTabs[0].getAttribute("open")).toBe("false"); - expect(goaTabs[1].getAttribute("open")).toBe("false"); + expect(goaTabs[1].getAttribute("open")).toBe("true"); + expect(goaTabs[2].getAttribute("open")).toBe("false"); }, { timeout: 2000 }, ); - // WHEN - Change hash to #tab-0 (should activate "Tab 1" - first tab, zero-indexed) - window.location.hash = "#tab-0"; + window.location.hash = "#tab-3"; window.dispatchEvent(new Event("hashchange")); - // THEN - Tab 1 should be open (index 0) await vi.waitFor( () => { - goaTabs = getTabs(); - expect(goaTabs[0].getAttribute("open")).toBe("true"); // "Tab 1" at index 0 + const goaTabs = getTabs(); + expect(goaTabs[0].getAttribute("open")).toBe("false"); expect(goaTabs[1].getAttribute("open")).toBe("false"); - expect(goaTabs[2].getAttribute("open")).toBe("false"); + expect(goaTabs[2].getAttribute("open")).toBe("true"); }, { timeout: 2000 }, ); @@ -426,177 +422,283 @@ describe("Tabs Browser Tests", () => { }); }); }); - describe("disabled", () => { - it("should not show the disabled tab even initial tab is that tab", async () => { - // GIVEN - Tab 1 is disabled but initialTab is set to 1 + + describe("slug prop", () => { + it("should use slug prop in tab href attribute", async () => { const Component = () => { return ( - - -

Content 1 - This should NOT be visible

-
- -

Content 2 - This SHOULD be visible on load

+ + + Overview content - -

Content 3

+ + Details content + Summary content
); }; - const { getByTestId, getByText } = render(); + const { getByRole } = render(); + const tablist = getByRole("tab"); - // Wait for component to fully render await vi.waitFor(() => { - expect(getByTestId("test-tabs")).toBeTruthy(); + expect(tablist.elements().length).toBe(3); }); - // THEN - Tab 1 should be disabled with correct attributes + const tabs = tablist.elements(); + + // First tab should have custom slug in href + expect(tabs[0].getAttribute("href")).toContain("#overview-tab"); + + // Second tab should have custom slug in href + expect(tabs[1].getAttribute("href")).toContain("#details-section"); + + // Third tab without slug should use default tab-{index} format + expect(tabs[2].getAttribute("href")).toContain("#summary"); + }); + + it("should navigate to correct hash when clicking tab with slug", async () => { + const Component = () => { + return ( + + First content + + Second content + + + ); + }; + + const { getByRole } = render(); + const tablist = getByRole("tab"); + await vi.waitFor(() => { - const tab1 = getByTestId("tab-1"); - expect(tab1.element().getAttribute("aria-disabled")).toBe("true"); - expect(tab1.element().getAttribute("aria-selected")).toBe("false"); - expect(tab1.element().getAttribute("tabindex")).toBe("-1"); + expect(tablist.elements().length).toBe(2); }); - // THEN - Tab 2 should be active (since Tab 1 is disabled) + const secondTab = tablist.elements()[1]; + await secondTab.click(); + + // Hash should be the custom slug await vi.waitFor(() => { - const tab2 = getByTestId("tab-2"); - expect(tab2.element().getAttribute("aria-selected")).toBe("true"); - expect(tab2.element().getAttribute("tabindex")).toBe("0"); - // Content 2 should be visible - expect(getByText("Content 2 - This SHOULD be visible on load")).toBeTruthy(); + expect(window.location.hash).toBe("#second-custom"); }); + }); + + it("should activate tab when URL hash matches slug", async () => { + // Set hash before rendering + window.history.pushState({}, "", "/test#details-tab"); - // WHEN - Press arrow right to move to Tab 3 - const tab2 = getByTestId("tab-2"); - tab2.element().focus(); - await tab2 - .element() - .dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + const Component = () => { + return ( + + Home content + + Details content + + About content + ); + }; + + const { getByText, getByRole } = render(); + const tablist = getByRole("tab"); - // THEN - Tab 3 should be active await vi.waitFor(() => { - const tab3 = getByTestId("tab-3"); - expect(tab3.element().getAttribute("aria-selected")).toBe("true"); - expect(tab3.element().getAttribute("tabindex")).toBe("0"); - // Content 3 should be visible - expect(getByText("Content 3")).toBeTruthy(); + expect(tablist.elements().length).toBe(3); }); - // WHEN - Press arrow right again (should skip Tab 1 and go to Tab 2) - const tab3 = getByTestId("tab-3"); - await tab3 - .element() - .dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), - ); - - // THEN - Tab 2 should be active (Tab 1 is skipped because it's disabled) + // The tab with matching slug should be active await vi.waitFor(() => { - const tab2After = getByTestId("tab-2"); - expect(tab2After.element().getAttribute("aria-selected")).toBe("true"); - expect(tab2After.element().getAttribute("tabindex")).toBe("0"); - // Content 2 should be visible again - expect(getByText("Content 2 - This SHOULD be visible on load")).toBeTruthy(); + const tabs = tablist.elements(); + expect(tabs[1].getAttribute("aria-selected")).toBe("true"); + expect(getByText("Details content")).toBeTruthy(); }); }); - it("should skip disabled tab when navigating with arrow left", async () => { - // GIVEN - Tab 2 is disabled + it("should use slug prop in href URL hash when provided", async () => { const Component = () => { return ( - -

Content 1

+ + Review content - -

Content 2 - This should NOT be visible

+ + Complete + + + } + slug="complete-items" + > + Complete content - -

Content 3

+ + Draft + + + } + > + Draft content
); }; - const { getByTestId, getByText } = render(); + const { getByText, getByRole } = render(); + const tablist = getByRole("tab"); - // Wait for component to fully render await vi.waitFor(() => { - expect(getByTestId("test-tabs")).toBeTruthy(); + expect(tablist.elements().length).toBe(3); }); - // THEN - Tab 1 should be active initially - await vi.waitFor(() => { - const tab1 = getByTestId("tab-1"); - expect(tab1.element().getAttribute("aria-selected")).toBe("true"); - }); + const tabs = tablist.elements(); + + // First tab with component heading should have custom slug in href + expect(tabs[0].getAttribute("href")).toContain("#review-pending"); - // Navigate to Tab 3 first (skip Tab 2 which is disabled) - const tab1 = getByTestId("tab-1"); - tab1.element().focus(); - await tab1 - .element() - .dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }), + // Second tab with component heading should have custom slug in href + expect(tabs[1].getAttribute("href")).toContain("#complete-items"); + + // Third tab with component heading but no slug should use default tab-{index} format + expect(tabs[2].getAttribute("href")).toContain("#tab-2"); + }); + + it("should navigate correctly when clicking tab with component heading and slug", async () => { + const Component = () => { + return ( + + Plain content + + Status Tab + + + } + slug="status-updates" + > + Status content + + ); + }; + + const { getByText, getByRole } = render(); + const tabs = getByRole("tab"); - // THEN - Tab 3 should be active (Tab 2 is skipped) await vi.waitFor(() => { - const tab3 = getByTestId("tab-3"); - expect(tab3.element().getAttribute("aria-selected")).toBe("true"); - expect(getByText("Content 3")).toBeTruthy(); + expect(tabs.elements().length).toBe(2); }); - // WHEN - Press arrow left (should skip Tab 2 and go to Tab 1) - const tab3 = getByTestId("tab-3"); - await tab3 - .element() - .dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + const secondTab = tabs.elements()[1]; + await secondTab.click(); - // THEN - Tab 1 should be active (Tab 2 is skipped) + // Hash should be the custom slug await vi.waitFor(() => { - const tab1After = getByTestId("tab-1"); - expect(tab1After.element().getAttribute("aria-selected")).toBe("true"); - expect(getByText("Content 1")).toBeTruthy(); + expect(window.location.hash).toBe("#status-updates"); + }); + + // Content should be visible + await vi.waitFor(() => { + expect(getByText("Status content")).toBeTruthy(); }); }); - }); - describe("variant segmented", () => { - it("should render segmented tabs", async () => { - // GIVEN - Tabs with variant="segmented" + + it("should activate tab with component heading when URL hash matches slug", async () => { + // Set hash before rendering + window.history.pushState({}, "", "/test#active-tasks"); + const Component = () => { return ( - - -

Overview content

-
- -

Details content

-
- -

Settings content

+ + All content + + Active + + + } + slug="active-tasks" + > + Active content + Archived content ); }; - const { getByTestId } = render(); - // Wait for component to fully render + const { getByText, getByRole } = render(); + const tabs = getByRole("tab"); + await vi.waitFor(() => { - expect(getByTestId("segment-tabs")).toBeTruthy(); + expect(tabs.elements().length).toBe(3); }); - // THEN - The tablist container should have the "segmented" class + // The tab with matching slug should be active await vi.waitFor(() => { - const tabsContainer = getByTestId("segment-tabs"); - expect(tabsContainer.element().classList.contains("segmented")).toBe(true); + const els = tabs.elements(); + expect(els.length).toBe(3); + expect(els[1].getAttribute("aria-selected")).toBe("true"); + expect(getByText("Active content")).toBeTruthy(); }); }); + + /** + * Previously if you clicked on a tab, then reloaded the page, the tab hash value + * would be duplicated in the browser's url bar + */ + it("should ensure that a hash value does not exist in duplicate", async () => { + // set initial hash in the url + window.location.hash = "active-tasks"; + + const Component = () => { + return ( + + All content + + Active + + + } + slug="active-tasks" + > + Active content + + Archived content + + ); + }; + + const { getByText, getByRole } = render(); + const tabs = getByRole("tab"); + + await vi.waitFor(() => { + expect(tabs.elements().length).toBe(3); + }); + + // click the current tab + const tab = tabs.elements()[1]; + await tab.click(); + + // The url hash should not change. This logic was needed as without it, it would occasionally + // pass and the vitest would see that as a fully passing test. The logic below ensure that + // is passes all the time. + const hashes = new Set(); + await vi.waitFor(() => { + hashes.add(window.location.hash); + }, { timeout: 500 }); + + expect(hashes.size).toBe(1); + expect(hashes.has("#active-tasks")).toBe(true); + }) }); }); diff --git a/libs/react-components/src/lib/tab/tab.spec.tsx b/libs/react-components/src/lib/tab/tab.spec.tsx deleted file mode 100644 index d6484a3b27..0000000000 --- a/libs/react-components/src/lib/tab/tab.spec.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { render } from "@testing-library/react"; -import { GoabTab } from "./tab"; - -describe("GoabTab", () => { - it("should render successfully", () => { - const { container } = render( - -

- Profile: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. -

-
, - ); - expect(container.querySelector("goa-tab")).toBeTruthy(); - const heading = container.querySelector("[slot='heading']"); - expect(heading?.innerHTML).toContain("Profile"); - const content = container.querySelector("p"); - expect(content?.innerHTML).toContain("Lorem ipsum dolor sit amet"); - }); - - it("should render with disabled attribute when disabled is true", () => { - const { container } = render( - -

Disabled content

-
, - ); - const tab = container.querySelector("goa-tab"); - expect(tab).toBeTruthy(); - expect(tab?.getAttribute("disabled")).toBe("true"); - }); - - it("should not have disabled attribute when disabled is false", () => { - const { container } = render( - -

Enabled content

-
, - ); - const tab = container.querySelector("goa-tab"); - expect(tab).toBeTruthy(); - expect(tab?.getAttribute("disabled")).toBeNull(); - }); - - it("should not have disabled attribute when disabled is not provided", () => { - const { container } = render( - -

Default content

-
, - ); - const tab = container.querySelector("goa-tab"); - expect(tab).toBeTruthy(); - expect(tab?.getAttribute("disabled")).toBeNull(); - }); -}); diff --git a/libs/react-components/src/lib/tab/tab.tsx b/libs/react-components/src/lib/tab/tab.tsx index 08b94c06b1..44faa0418a 100644 --- a/libs/react-components/src/lib/tab/tab.tsx +++ b/libs/react-components/src/lib/tab/tab.tsx @@ -2,6 +2,7 @@ import type { JSX } from "react"; interface WCProps { heading?: React.ReactNode; disabled?: string; + slug?: string; } declare module "react" { @@ -17,12 +18,17 @@ export interface GoabTabItemProps { heading?: React.ReactNode; disabled?: boolean; children?: React.ReactNode; + slug?: string; } -export function GoabTab({ heading, disabled, children }: GoabTabItemProps): JSX.Element { +export function GoabTab({ heading, disabled, slug, children }: GoabTabItemProps): JSX.Element { return ( - - {heading && {heading}} + + {typeof heading !== "string" && {heading}} {children} ); diff --git a/libs/react-components/src/lib/tabs/tabs.spec.tsx b/libs/react-components/src/lib/tabs/tabs.spec.tsx index 9c8bbeff8a..7656263cb0 100644 --- a/libs/react-components/src/lib/tabs/tabs.spec.tsx +++ b/libs/react-components/src/lib/tabs/tabs.spec.tsx @@ -23,39 +23,25 @@ describe("Tabs", () => { expect(tabElements.length).toBe(1); }); - it("should render with variant='segmented'", () => { + it("should render tabs with slug props", () => { const { baseElement } = render( - - Content 1 - Content 2 + + +

Overview content

+
+ +

Details content

+
, ); - const el = baseElement.querySelector("goa-tabs"); - expect(el).toBeTruthy(); - expect(el?.getAttribute("variant")).toBe("segmented"); - }); - it("should render with variant='default'", () => { - const { baseElement } = render( - - Content 1 - Content 2 - , - ); - const el = baseElement.querySelector("goa-tabs"); - expect(el).toBeTruthy(); - expect(el?.getAttribute("variant")).toBe("default"); - }); + const tabElements = baseElement.querySelectorAll("goa-tab"); + expect(tabElements.length).toBe(2); - it("should not have variant attribute when variant is not provided", () => { - const { baseElement } = render( - - Content 1 - Content 2 - , - ); - const el = baseElement.querySelector("goa-tabs"); - expect(el).toBeTruthy(); - expect(el?.getAttribute("variant")).toBeNull(); + // First tab should have slug attribute + expect(tabElements[0].getAttribute("slug")).toBe("overview-section"); + + // Second tab should not have slug attribute (or null/undefined) + expect(tabElements[1].getAttribute("slug")).toBeNull(); }); }); diff --git a/libs/web-components/src/components/tab/Tab.svelte b/libs/web-components/src/components/tab/Tab.svelte index 9370d02ac6..8f5695c30d 100644 --- a/libs/web-components/src/components/tab/Tab.svelte +++ b/libs/web-components/src/components/tab/Tab.svelte @@ -12,6 +12,7 @@ heading: HTMLSlotElement | string; open: boolean; disabled: boolean; + slug: string; } @@ -26,6 +27,7 @@ export let heading: string = ""; export let open: boolean = false; export let disabled: boolean = false; + export let slug: string = ""; // ======= // Private @@ -59,8 +61,9 @@ el: _rootEl, headingType, heading: headingType === "string" ? heading : getHeadingContents(), - open: open, - disabled: disabled + disabled, + open, + slug, } })) }, 1); diff --git a/libs/web-components/src/components/tab/tab.spec.ts b/libs/web-components/src/components/tab/tab.spec.ts index ab79ae1120..dfb7136a16 100644 --- a/libs/web-components/src/components/tab/tab.spec.ts +++ b/libs/web-components/src/components/tab/tab.spec.ts @@ -2,14 +2,14 @@ import { render } from "@testing-library/svelte"; import GoATab from "./Tab.svelte"; import { it, expect } from "vitest"; -it('should render tabpanel when open is set', async () => { - const result = render(GoATab, { heading: 'Title', open: true }); +it("should render tabpanel when open is set", async () => { + const result = render(GoATab, { heading: "Title", open: true }); expect(result.container.innerHTML).toContain("Title"); - expect(result.container.querySelector('div[role="tabpanel"]')).toBeVisible(); + expect(result.container.querySelector("div[role='tabpanel']")).toBeVisible(); }); -it('should not render tabpanel when open is not true', async () => { - const result = render(GoATab, { heading: 'Title' }); +it("should not render tabpanel when open is not true", async () => { + const result = render(GoATab, { heading: "Title" }); expect(result.container.innerHTML).toContain("Title"); - expect(result.container.querySelector('div[role="tabpanel"]')).not.toBeVisible(); + expect(result.container.querySelector("div[role='tabpanel']")).not.toBeVisible(); }); diff --git a/libs/web-components/src/components/tabs/Tabs.svelte b/libs/web-components/src/components/tabs/Tabs.svelte index 49173383fd..b44f8688ad 100644 --- a/libs/web-components/src/components/tabs/Tabs.svelte +++ b/libs/web-components/src/components/tabs/Tabs.svelte @@ -94,7 +94,6 @@ for (let i = 0; i < tabs.length; i++) { const tab = tabs[i] as HTMLAnchorElement; - const tabHref = tab.getAttribute("href"); const tabHash = tabHref?.split("#")[1] || ""; @@ -108,7 +107,6 @@ function addChildMountListener() { _rootEl.addEventListener("tab:mounted", (e: Event) => { const detail = (e as CustomEvent).detail; - // tabs initially marked as unbound _tabProps = [..._tabProps, { ...detail, bound: false }]; @@ -167,11 +165,13 @@ // create tabs if (tabProps.headingType === "slot") { headingEl = tabProps.heading as HTMLElement; + tabSlug = tabProps.slug; } else { const heading = tabProps.heading as string; + headingEl = document.createElement("div"); headingEl.textContent = heading; - tabSlug = heading; + tabSlug = tabProps.slug || toSlug(heading); } headingEl.classList.add("tab"); @@ -295,11 +295,15 @@ } let currentLocation = ""; + + // send message to each tab to set visibility within // @ts-expect-error [..._tabsEl.querySelectorAll("[role=tab]")].map((el, index) => { const isCurrent = index + 1 === +_currentTab; // currentTab is 1-based + el.setAttribute("aria-selected", fromBoolean(isCurrent)); el.setAttribute("tabindex", isCurrent ? "0" : "-1"); + if (isCurrent) { currentLocation = (el as HTMLLinkElement).href; el.focus(); @@ -326,7 +330,8 @@ // to make sure we preserve multiple #, for example /#tab-1#example const allHashes = window.location.href.split('#').slice(1); const otherHashes = allHashes.filter(hash => !hash.startsWith('tab-')); // #example - const newHash = [url.hash.substring(1), ...otherHashes].filter(Boolean).join('#'); + const uniqHashes = [...new Set([url.hash.substring(1), ...otherHashes])]; + const newHash = uniqHashes.filter(Boolean).join('#'); history.replaceState({}, "", url.pathname + url.search + (newHash ? '#' + newHash : '')); @@ -394,6 +399,13 @@ e.preventDefault(); } } + + /** Converts the input string to a kebab format url encoded string */ + function toSlug(input: string): string { + const parts = input.toLowerCase().split(" "); + const str = parts.map(val => val.toLowerCase()).join("-"); + return encodeURIComponent(str); + } diff --git a/libs/web-components/src/components/tabs/TabsWrapperWithSlug.test.svelte b/libs/web-components/src/components/tabs/TabsWrapperWithSlug.test.svelte new file mode 100644 index 0000000000..7b6b81cda2 --- /dev/null +++ b/libs/web-components/src/components/tabs/TabsWrapperWithSlug.test.svelte @@ -0,0 +1,16 @@ + + + + + + + Tab content with slug + + + Tab content without slug + + diff --git a/libs/web-components/src/components/tabs/tabs.spec.ts b/libs/web-components/src/components/tabs/tabs.spec.ts index 1e34543c38..dc62ccf5f2 100644 --- a/libs/web-components/src/components/tabs/tabs.spec.ts +++ b/libs/web-components/src/components/tabs/tabs.spec.ts @@ -219,4 +219,41 @@ describe("Tabs", () => { writable: true }); }); + + it("should use slug prop in tab href when provided", async () => { + const SlugTabs = await import("./TabsWrapperWithSlug.test.svelte"); + const { container } = render(SlugTabs.default); + + await waitFor(() => { + const tab1Link = container.querySelector("a#tab-1") as HTMLAnchorElement; + const tab2Link = container.querySelector("a#tab-2") as HTMLAnchorElement; + + expect(tab1Link).toBeTruthy(); + expect(tab2Link).toBeTruthy(); + + // First tab with slug should use the slug value + expect(tab1Link.getAttribute("href")).toContain("#review-pending"); + + // Second tab without slug should default to "tab-{index}" where index is 1 + expect(tab2Link.getAttribute("href")).toContain("#complete"); + }); + }); + + it("should navigate to tab when clicking link with slug", async () => { + const SlugTabs = await import("./TabsWrapperWithSlug.test.svelte"); + const { container } = render(SlugTabs.default); + + await waitFor(() => { + const tab1Link = container.querySelector("a#tab-1") as HTMLElement; + expect(tab1Link).toBeTruthy(); + }); + + const tab1Link = container.querySelector("a#tab-1") as HTMLElement; + await fireEvent.click(tab1Link); + + await waitFor(() => { + expect(window.location.hash).toContain("review-pending"); + expect(tab1Link.getAttribute("aria-selected")).toBe("true"); + }); + }); }); From 0f8d664955dc5bf824aa5a4ac99624c038fff940 Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Tue, 13 Jan 2026 19:31:13 -0700 Subject: [PATCH 12/26] docs: add JSDoc prop descriptions to Svelte components Add documentation comments to component props across 67 components. These descriptions enable better IDE hints and support automated documentation generation. --- .../src/components/accordion/Accordion.svelte | 12 ++++ .../app-header-menu/AppHeaderMenu.svelte | 6 ++ .../components/app-header/AppHeader.svelte | 6 ++ .../src/components/badge/Badge.svelte | 16 ++++- .../src/components/block/Block.svelte | 12 +++- .../button-group/ButtonGroup.svelte | 9 ++- .../src/components/button/Button.svelte | 25 ++++++- .../src/components/calendar/Calendar.svelte | 11 +++- .../src/components/callout/Callout.svelte | 14 +++- .../components/card-image/CardImage.svelte | 2 + .../src/components/card/Card.svelte | 10 ++- .../checkbox-list/CheckboxList.svelte | 10 +++ .../src/components/checkbox/Checkbox.svelte | 21 +++++- .../src/components/chip/Chip.svelte | 12 +++- .../circular-progress/CircularProgress.svelte | 6 ++ .../src/components/container/Container.svelte | 13 ++++ .../src/components/data-grid/DataGrid.svelte | 3 + .../components/date-picker/DatePicker.svelte | 18 +++-- .../src/components/details/Details.svelte | 9 +++ .../src/components/divider/Divider.svelte | 7 +- .../src/components/drawer/Drawer.svelte | 7 +- .../src/components/dropdown/Dropdown.svelte | 31 +++++++-- .../file-upload-card/FileUploadCard.svelte | 6 ++ .../file-upload-input/FileUploadInput.svelte | 4 ++ .../components/filter-chip/FilterChip.svelte | 12 +++- .../components/focus-trap/FocusTrap.svelte | 5 +- .../FooterMetaSection.svelte | 1 + .../FooterNavSection.svelte | 3 + .../src/components/footer/Footer.svelte | 3 + .../src/components/form-item/FormItem.svelte | 19 +++++- .../src/components/form-step/FormStep.svelte | 3 + .../form-stepper/FormStepper.svelte | 7 +- .../src/components/form/Form.svelte | 3 +- .../src/components/grid/Grid.svelte | 9 ++- .../components/hero-banner/HeroBanner.svelte | 7 ++ .../components/icon-button/IconButton.svelte | 14 +++- .../src/components/icon/Icon.svelte | 16 +++++ .../src/components/input/Input.svelte | 66 +++++++++++++++++++ .../linear-progress/LinearProgress.svelte | 5 ++ .../components/link-button/LinkButton.svelte | 7 ++ .../src/components/link/Link.svelte | 12 ++++ .../components/menu-button/MenuButton.svelte | 5 ++ .../microsite-header/MicrositeHeader.svelte | 8 +++ .../src/components/modal/Modal.svelte | 10 ++- .../notification/Notification.svelte | 7 ++ .../components/page-block/PageBlock.svelte | 3 + .../src/components/pages/Pages.svelte | 8 ++- .../components/pagination/Pagination.svelte | 10 +++ .../src/components/popover/Popover.svelte | 40 ++++++----- .../components/radio-group/RadioGroup.svelte | 13 ++++ .../components/radio-item/RadioItem.svelte | 17 ++++- .../components/scrollable/Scrollable.svelte | 7 ++ .../side-menu-group/SideMenuGroup.svelte | 8 +++ .../side-menu-heading/SideMenuHeading.svelte | 3 + .../src/components/side-menu/SideMenu.svelte | 2 + .../src/components/skeleton/Skeleton.svelte | 11 +++- .../src/components/spacer/Spacer.svelte | 4 ++ .../src/components/spinner/Spinner.svelte | 6 +- .../src/components/tab/Tab.svelte | 2 + .../src/components/table/Table.svelte | 10 +++ .../src/components/tabs/Tabs.svelte | 5 +- .../TemporaryNotification.svelte | 8 +++ .../src/components/text-area/TextArea.svelte | 19 ++++++ .../src/components/text/Text.svelte | 8 +++ .../src/components/tooltip/Tooltip.svelte | 9 +++ .../work-side-menu/WorkSideMenu.svelte | 7 ++ .../work-side-menu/WorkSideMenuItem.svelte | 8 +++ 67 files changed, 641 insertions(+), 59 deletions(-) diff --git a/libs/web-components/src/components/accordion/Accordion.svelte b/libs/web-components/src/components/accordion/Accordion.svelte index 4fefe5d9b0..667345e412 100644 --- a/libs/web-components/src/components/accordion/Accordion.svelte +++ b/libs/web-components/src/components/accordion/Accordion.svelte @@ -26,17 +26,29 @@ // Props + /** Sets the state of the accordion container open or closed. */ export let open: string = "false"; + /** @required Sets the heading text. */ export let heading: string = ""; + /** Sets secondary text. */ export let secondarytext: string = ""; + /** Sets the heading size of the accordion container heading. */ export let headingsize: HeadingSize = "small"; + /** Unique identifier for the accordion. Auto-generated if not provided. */ export let id: string = ""; + /** Sets the maximum width of the accordion. */ export let maxwidth: string = "none"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = "xs"; + /** Left margin. */ export let ml: Spacing = null; + /** Sets the position of the expand/collapse icon. */ export let iconposition: "left" | "right" = "left"; // Private diff --git a/libs/web-components/src/components/app-header-menu/AppHeaderMenu.svelte b/libs/web-components/src/components/app-header-menu/AppHeaderMenu.svelte index 41ce86cc1c..0cd5f13076 100644 --- a/libs/web-components/src/components/app-header-menu/AppHeaderMenu.svelte +++ b/libs/web-components/src/components/app-header-menu/AppHeaderMenu.svelte @@ -16,11 +16,17 @@ import { TABLET_BP } from "../../common/breakpoints"; // Required + + /** @required The menu heading text displayed as the dropdown trigger. */ export let heading: string; // Optional + + /** Icon displayed before the heading text. */ export let leadingicon: GoAIconType; + /** The menu style variant. Primary uses bold text, secondary uses regular weight. */ export let type: "primary" | "secondary" = "primary"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = "rootEl"; // Private diff --git a/libs/web-components/src/components/app-header/AppHeader.svelte b/libs/web-components/src/components/app-header/AppHeader.svelte index 165967aee2..4d39b2da06 100644 --- a/libs/web-components/src/components/app-header/AppHeader.svelte +++ b/libs/web-components/src/components/app-header/AppHeader.svelte @@ -10,11 +10,17 @@ import type { AppHeaderMenuProps } from "../app-header-menu/AppHeaderMenu.svelte"; // optional + /** Set the service name to display in the app header. */ export let heading: string = ""; + /** Set the URL to link from the alberta.ca logo. A full url is required. */ export let url: string = ""; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Maximum width of the content area. */ export let maxcontentwidth = ""; + /** Sets the breakpoint in px for the full menu to display. */ export let fullmenubreakpoint: number = TABLET_BP; // minimum window width to show all menu links + /** When true, clicking the menu button dispatches _menuClick event instead of toggling the menu. Use for custom menu handling. */ export let hasmenuclickhandler: string = "false"; // If this is yes, we will not expand menu when clicking a toggle button // Private diff --git a/libs/web-components/src/components/badge/Badge.svelte b/libs/web-components/src/components/badge/Badge.svelte index 6569984ef9..024003749a 100644 --- a/libs/web-components/src/components/badge/Badge.svelte +++ b/libs/web-components/src/components/badge/Badge.svelte @@ -71,22 +71,34 @@ type BadgeSize = (typeof badgeSizes)[number]; type BadgeVersion = (typeof versions)[number]; + /** @required Defines the context and colour of the badge. */ export let type: BadgeType; - // optional + // Optional + /** Sets the data-testid attribute. Used with ByTestId queries in tests. */ export let testid: string = ""; + /** Text label of the badge. */ export let content: string = ""; + /** @deprecated Use icontype instead. Includes an icon in the badge. */ export let icon: string = ""; + /** Icon type to display in the badge. */ export let icontype: GoAIconType | null = null; + /** Accessible label for screen readers. */ export let arialabel: string = ""; + /** Sets the size of the badge. */ export let size: BadgeSize = "medium"; + /** Sets the visual emphasis. 'subtle' for less prominent, 'strong' for more emphasis. */ export let emphasis: (typeof emphasisLevels)[number] = "strong"; + /** The design system version for styling purposes. */ export let version: BadgeVersion = "1"; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; // private diff --git a/libs/web-components/src/components/block/Block.svelte b/libs/web-components/src/components/block/Block.svelte index a03bbafa23..a21bf923aa 100644 --- a/libs/web-components/src/components/block/Block.svelte +++ b/libs/web-components/src/components/block/Block.svelte @@ -15,12 +15,19 @@ import { ensureSlotExists } from "../../common/utils"; import { style, styles } from "../../common/utils"; + /** Spacing between items. Uses design system spacing tokens. */ export let gap: Spacing = "m"; + /** Stacking direction of child components. */ export let direction: "row" | "column" = "row"; + /** Primary axis alignment of child components. */ export let alignment: "center" | "start" | "end" | "normal" = "normal"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Sets the minimum width of the block container. */ export let minWidth: string = ""; + /** Sets the maximum width of the block container. */ export let maxWidth: string = ""; + /** Sets the width of the block container. Defaults to max-content. */ export let width: string = ""; $: _alignment = @@ -32,10 +39,13 @@ ? "center" : "normal"; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; // Private diff --git a/libs/web-components/src/components/button-group/ButtonGroup.svelte b/libs/web-components/src/components/button-group/ButtonGroup.svelte index db4a3a2f56..bff6fa34ad 100644 --- a/libs/web-components/src/components/button-group/ButtonGroup.svelte +++ b/libs/web-components/src/components/button-group/ButtonGroup.svelte @@ -7,14 +7,21 @@ import { onMount } from "svelte"; import { typeValidator } from "../../common/utils"; + /** @required Positions the button group in the page layout. */ + export let alignment: ButtonAlignment = "start"; + /** Sets the spacing between buttons in the button group. */ export let gap: Gap = "relaxed"; + /** Sets the data-testid attribute. Used with ByTestId queries in tests. */ export let testid: string = ""; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; const [BUTTON_ALIGNMENTS, validateAlignment] = typeValidator("alignment", [ diff --git a/libs/web-components/src/components/button/Button.svelte b/libs/web-components/src/components/button/Button.svelte index a5b3099bc1..73ad6a9a4f 100644 --- a/libs/web-components/src/components/button/Button.svelte +++ b/libs/web-components/src/components/button/Button.svelte @@ -44,20 +44,43 @@ type Variant = (typeof Variants)[number]; type Version = (typeof Versions)[number]; - // optional + /** Sets the visual style of the button. Use "primary" for main actions, "secondary" for alternative actions, "tertiary" for low-emphasis actions, and "start" for prominent call-to-action buttons. */ export let type: ButtonType = "primary"; + + /** Controls the size of the button. Use "compact" for inline actions or space-constrained layouts. */ export let size: Size = "normal"; + + /** Sets the color variant for semantic meaning. Use "destructive" for delete or irreversible actions, "inverse" for dark backgrounds. */ export let variant: Variant = "normal"; + + /** When true, prevents user interaction and applies disabled styling. */ export let disabled: string = "false"; + + /** Icon displayed before the button text. */ export let leadingicon: GoAIconType | null = null; + + /** Icon displayed after the button text. */ export let trailingicon: GoAIconType | null = null; + + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + + /** Sets a custom width for the button (e.g., "200px" or "100%"). */ export let width: string = ""; + + /** Design system version. Version 2 includes updated styling and accessibility improvements. */ export let version: Version = "1"; + /** Sets the top margin using design system spacing tokens. */ export let mt: Spacing = null; + + /** Sets the right margin using design system spacing tokens. */ export let mr: Spacing = null; + + /** Sets the bottom margin using design system spacing tokens. */ export let mb: Spacing = null; + + /** Sets the left margin using design system spacing tokens. */ export let ml: Spacing = null; export let action: string = ""; diff --git a/libs/web-components/src/components/calendar/Calendar.svelte b/libs/web-components/src/components/calendar/Calendar.svelte index 1b3dcb2e04..9b2a144026 100644 --- a/libs/web-components/src/components/calendar/Calendar.svelte +++ b/libs/web-components/src/components/calendar/Calendar.svelte @@ -10,23 +10,32 @@ // Public // ****** + /** Name identifier for the calendar, used in form submission and change events. */ export let name: string = ""; + /** The currently selected date value in YYYY-MM-DD format. */ export let value: string = ""; + /** The minimum selectable date in YYYY-MM-DD format. Defaults to 5 years in the past. */ export let min: string = ""; + /** The maximum selectable date in YYYY-MM-DD format. Defaults to 5 years in the future. */ export let max: string = ""; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; export let version: "1" | "2" = "1"; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; // **************** // Exposed Privates // **************** + /** Shows a border around the calendar. Set to false when embedding within another component. */ export let bordered: string = "true"; // ******** diff --git a/libs/web-components/src/components/callout/Callout.svelte b/libs/web-components/src/components/callout/Callout.svelte index 394226a1c4..f12f413c28 100644 --- a/libs/web-components/src/components/callout/Callout.svelte +++ b/libs/web-components/src/components/callout/Callout.svelte @@ -37,20 +37,32 @@ type AriaLiveType = (typeof AriaLive)[number]; type VersionType = (typeof Version)[number]; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = "l"; + /** Left margin. */ export let ml: Spacing = null; + /** The medium callout has reduced padding and type size to adjust for a compact area and smaller viewport width when a smaller size is required. */ export let size: CalloutSize = "large"; + /** @required Define the context and colour of the callout. */ export let type: CalloutType; + /** Sets the visual prominence. 'high' for full background, 'medium' for subtle, 'low' for minimal. */ export let emphasis: CalloutEmphasisType = "medium"; + /** Callout heading text. */ export let heading: string = ""; + /** Sets the maximum width of the callout. */ export let maxwidth: string = "none"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Indicates how assistive technology should handle updates to the live region. */ export let arialive: AriaLiveType = "off"; + /** Sets the icon theme. 'outline' for stroked icons, 'filled' for solid icons. */ export let icontheme: IconTheme = "outline"; + /** The design system version for styling purposes. */ export let version: VersionType = "1"; // Private diff --git a/libs/web-components/src/components/card-image/CardImage.svelte b/libs/web-components/src/components/card-image/CardImage.svelte index de18ca7586..0834cb696a 100644 --- a/libs/web-components/src/components/card-image/CardImage.svelte +++ b/libs/web-components/src/components/card-image/CardImage.svelte @@ -2,7 +2,9 @@ diff --git a/libs/web-components/src/components/card/Card.svelte b/libs/web-components/src/components/card/Card.svelte index 03792c7b08..5dc3c06c80 100644 --- a/libs/web-components/src/components/card/Card.svelte +++ b/libs/web-components/src/components/card/Card.svelte @@ -5,17 +5,23 @@ import type { Spacing } from "../../common/styling"; import { calculateMargin } from "../../common/styling"; + /** Adds a shadow to the card. 0 shows a border, 1-3 increase shadow intensity. */ export let elevation: number = 0; + /** Sets the width of the card. */ export let width: string = "100%"; + /** Sets the height behavior. 'auto' fits content, 'max' fills available height. */ export let height: "auto" | "max" = "auto"; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; - //optional + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; diff --git a/libs/web-components/src/components/checkbox-list/CheckboxList.svelte b/libs/web-components/src/components/checkbox-list/CheckboxList.svelte index fa5f619d19..60fe4a4f63 100644 --- a/libs/web-components/src/components/checkbox-list/CheckboxList.svelte +++ b/libs/web-components/src/components/checkbox-list/CheckboxList.svelte @@ -25,17 +25,27 @@ FieldsetErrorRelayDetail, } from "../../types/relay-types"; + /** @required The name for the checkbox list group. Used for form submission. */ export let name: string; + /** Array of currently selected checkbox values. */ export let value: string[] = []; + /** Disables all checkboxes in the list. */ export let disabled: string = "false"; + /** Shows an error state on all checkboxes in the list. */ export let error: string = "false"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Sets the maximum width of the checkbox list container. */ export let maxwidth: string = "none"; + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; type ChildRecord = { el: HTMLElement; name: string; label?: string }; diff --git a/libs/web-components/src/components/checkbox/Checkbox.svelte b/libs/web-components/src/components/checkbox/Checkbox.svelte index eec0d0c4ad..5ca75c964e 100644 --- a/libs/web-components/src/components/checkbox/Checkbox.svelte +++ b/libs/web-components/src/components/checkbox/Checkbox.svelte @@ -25,27 +25,44 @@ } from "../../types/relay-types"; // Required + /** Unique name to identify the checkbox. */ export let name: string; // Optional values + /** Marks the checkbox item as selected. */ export let checked: string = "false"; + /** Shows a mixed/partial selection state. Used for 'Select All' checkboxes when some items are selected. */ export let indeterminate: string = "false"; + /** Label shown beside the checkbox. */ export let text: string = ""; + /** The value binding. */ export let value: string = ""; + /** Disable this control. It will not receive focus or events. */ export let disabled: string = "false"; + /** Shows an error on the checkbox item. */ export let error: string = "false"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Defines how the text will be translated for the screen reader. If not specified it will fall back to the name. */ export let arialabel: string = ""; + /** Additional description text displayed below the checkbox label. */ export let description: string = ""; - export let revealarialabel: string = ""; // screen reader will announce this when reveal slot is displayed + /** Text announced by screen readers when the reveal slot content is displayed. */ + export let revealarialabel: string = ""; + /** Sets the maximum width of the checkbox. */ export let maxwidth: string = "none"; + /** Sets the size of the checkbox. 'compact' reduces spacing for dense layouts. */ export let size: "default" | "compact" = "default"; + /** The design system version for styling purposes. */ export let version: "1" | "2" = "1"; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; // Private diff --git a/libs/web-components/src/components/chip/Chip.svelte b/libs/web-components/src/components/chip/Chip.svelte index 1f70dfecee..5d8cb4a69f 100644 --- a/libs/web-components/src/components/chip/Chip.svelte +++ b/libs/web-components/src/components/chip/Chip.svelte @@ -10,18 +10,28 @@ type ChipVariant = "filter"; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; + /** @deprecated Use GoAFilterChip instead. Icon displayed at the start of the chip. */ export let leadingicon: GoAIconType | null = null; + /** @deprecated Use GoAFilterChip instead. The icon theme - outline or filled. */ export let icontheme: IconTheme = "outline"; + /** @deprecated Use GoAFilterChip instead. Shows an error state on the chip. */ export let error: string = "false"; + /** @deprecated Use GoAFilterChip instead. When true, shows a delete icon and makes chip clickable. */ export let deletable: string = "false"; + /** @deprecated Use GoAFilterChip instead. The text content displayed in the chip. */ export let content: string; + /** @deprecated Use GoAFilterChip instead. The chip variant style. */ export let variant: ChipVariant; + /** @deprecated Use GoAFilterChip instead. Sets a data-testid attribute for automated testing. */ export let testid: string = ""; let el: HTMLElement; diff --git a/libs/web-components/src/components/circular-progress/CircularProgress.svelte b/libs/web-components/src/components/circular-progress/CircularProgress.svelte index 61691733a9..cec657cdfa 100644 --- a/libs/web-components/src/components/circular-progress/CircularProgress.svelte +++ b/libs/web-components/src/components/circular-progress/CircularProgress.svelte @@ -22,11 +22,17 @@ type Variant = (typeof Variants)[number]; // Optional + /** Stretch across the full screen or use it inline */ export let variant: Variant = "inline"; + /** Size of the progress indicator */ export let size: Size = "large"; + /** Loading message displayed under the progress indicator */ export let message: string = ""; + /** Set the progress value. Setting this value will change the type from infinite to progress */ export let progress: number = -1; + /** Show/hide the page loader. This allows for fade transition to be applied in each transition. */ export let visible: string = "false"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; $: isVisible = toBoolean(visible); diff --git a/libs/web-components/src/components/container/Container.svelte b/libs/web-components/src/components/container/Container.svelte index 015a521a1f..cb84c1fea0 100644 --- a/libs/web-components/src/components/container/Container.svelte +++ b/libs/web-components/src/components/container/Container.svelte @@ -47,17 +47,30 @@ // Props + /** Sets the container and accent bar styling. */ + export let type: Type = "interactive"; + /** Sets the style of accent on the container. */ export let accent: Accent = "filled"; + /** Sets the amount of white space in the container. */ export let padding: Padding = "relaxed"; + /** Sets the width of the container. */ export let width: Width = "full"; + /** Sets the maximum width of the container. */ export let maxWidth: string = "none"; + /** Sets the minimum height of the container. */ export let minHeight = ""; + /** Sets the maximum height of the container. */ export let maxHeight = ""; + /** Sets the data-testid attribute. Used with ByTestId queries in tests. */ export let testid: string = ""; + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = "m"; + /** Left margin. */ export let ml: Spacing = null; // Private diff --git a/libs/web-components/src/components/data-grid/DataGrid.svelte b/libs/web-components/src/components/data-grid/DataGrid.svelte index 77a6d2229c..d4f1090c6d 100644 --- a/libs/web-components/src/components/data-grid/DataGrid.svelte +++ b/libs/web-components/src/components/data-grid/DataGrid.svelte @@ -15,8 +15,11 @@ // Public // ****** + /** Controls visibility of the keyboard navigation indicator icon. Use "visible" to show or "hidden" to hide. */ export let keyboardIconVisibility: "visible" | "hidden" = "visible"; + /** Navigation mode. "table" navigates like a table (up/down between rows), "layout" allows wrapping between rows with left/right arrows. */ export let keyboardNav: "layout" | "table" = "table"; + /** Position of the keyboard navigation indicator icon. */ export let keyboardIconPosition: "left" | "right" = "left"; // Reactive diff --git a/libs/web-components/src/components/date-picker/DatePicker.svelte b/libs/web-components/src/components/date-picker/DatePicker.svelte index 7b3b256309..262cb151fe 100644 --- a/libs/web-components/src/components/date-picker/DatePicker.svelte +++ b/libs/web-components/src/components/date-picker/DatePicker.svelte @@ -31,26 +31,36 @@ valueStr: string; }; + /** Sets the date picker type. 'calendar' shows a calendar popup, 'input' shows just a date input. */ export let type: "calendar" | "input" = "calendar"; + /** Name of the date field. */ export let name: string = ""; + /** Value of the calendar date. */ export let value: string = ""; + /** Sets the input to an error state. */ export let error: string = "false"; + /** Minimum date value allowed. */ export let min: string = ""; + /** Maximum date value allowed. */ export let max: string = ""; - /*** - * @deprecated This property has no effect and will be removed in a future version - */ + /** @deprecated This property has no effect and will be removed in a future version. */ export let relative: string = ""; + /** Disables the date picker. */ export let disabled: string = "false"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Sets the width of the date picker input. */ export let width: string = ""; export let size: "default" | "compact" = "default"; export let version: "1" | "2" = "1"; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; let _error: boolean = toBoolean(error); diff --git a/libs/web-components/src/components/details/Details.svelte b/libs/web-components/src/components/details/Details.svelte index 23e22d9b66..566c18f898 100644 --- a/libs/web-components/src/components/details/Details.svelte +++ b/libs/web-components/src/components/details/Details.svelte @@ -10,13 +10,22 @@ } from "../../common/utils"; import type { Spacing } from "../../common/styling"; + /** @required The title heading */ + export let heading: string; + /** Sets the maximum width of the details. */ export let maxwidth: string = "75ch"; + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; + /** Controls if details is expanded or not. */ export let open: string = "false"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; let _isMouseOver: boolean = false; diff --git a/libs/web-components/src/components/divider/Divider.svelte b/libs/web-components/src/components/divider/Divider.svelte index f109784947..5b3c54d8a1 100644 --- a/libs/web-components/src/components/divider/Divider.svelte +++ b/libs/web-components/src/components/divider/Divider.svelte @@ -4,12 +4,17 @@ import { calculateMargin } from "../../common/styling"; import type { Spacing } from "../../common/styling"; + /** Sets the data-testid attribute. Used with ByTestId queries in tests. */ + export let testid: string = ""; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; diff --git a/libs/web-components/src/components/drawer/Drawer.svelte b/libs/web-components/src/components/drawer/Drawer.svelte index 6735036d51..d0503893d9 100644 --- a/libs/web-components/src/components/drawer/Drawer.svelte +++ b/libs/web-components/src/components/drawer/Drawer.svelte @@ -16,10 +16,15 @@ // Public // ****** + /** @required Whether the drawer is open. */ export let open = false; + /** @required The position of the drawer. */ export let position: DrawerPosition = undefined; + /** The heading text displayed at the top of the drawer. */ export let heading: string = ""; - export let maxsize: DrawerSize = undefined; // is set based on the anchor value + /** Sets max height on bottom position, sets width on left and right position. */ + export let maxsize: DrawerSize = undefined; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = "drawer"; // version diff --git a/libs/web-components/src/components/dropdown/Dropdown.svelte b/libs/web-components/src/components/dropdown/Dropdown.svelte index 3503d5fe99..504b0ba5fd 100644 --- a/libs/web-components/src/components/dropdown/Dropdown.svelte +++ b/libs/web-components/src/components/dropdown/Dropdown.svelte @@ -52,38 +52,57 @@ // Props + /** @required Identifier for the dropdown. Should be unique. */ export let name: string; + /** Defines how the selected value will be translated for the screen reader. If not specified it will fall back to the name. */ export let arialabel: string = ""; + /** The aria-labelledby attribute identifies the element(or elements) that labels the dropdown it is applied to. Normally it is the id of the label. */ export let arialabelledby: string = ""; + /** Stores the value of the item selected from the dropdown. */ export let value: string | undefined = ""; + /** When true the dropdown will have the ability to filter options by typing into the input field. */ export let filterable: string = "false"; + /** Show an icon to the left of the dropdown option. */ export let leadingicon: GoAIconType | null = null; + /** Maximum height of the dropdown menu items popover. Non-native only. */ export let maxheight: string = "276px"; + /** The text displayed for the dropdown before a selection is made. Non-native only. */ export let placeholder: string = ""; + /** Overrides the autosized menu width. Non-native only. */ export let width: string = ""; + /** Sets the maximum width of the dropdown. Use a CSS unit (px, %, ch, rem, em). */ export let maxwidth: string = ""; + /** Disable this control. */ export let disabled: string = "false"; + /** Show an error state. */ export let error: string = "false"; + /** When true, allows multiple items to be selected. */ export let multiselect: string = "false"; + /** When true will render the native select HTML element. */ export let native: string = "false"; + /** Sets the size of the dropdown. Compact reduces height for dense layouts. */ export let size: "default" | "compact" = "default"; + /** The design system version for styling purposes. */ export let version: "1" | "2" = "1"; - /*** - * @deprecated This property has no effect and will be removed in a future version - */ + /** @deprecated This property has no effect and will be removed in a future version. */ export let relative: string = ""; + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; + /** Specifies the autocomplete attribute for the dropdown input. Native only. */ export let autocomplete: string = ""; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; - /** - * Exposed Privates - **/ + // Exposed Privates + /** Prevents the popover from closing when clicking outside. Used for nested dropdowns or complex interactions. */ export let disableGlobalClosePopover: boolean = false; // diff --git a/libs/web-components/src/components/file-upload-card/FileUploadCard.svelte b/libs/web-components/src/components/file-upload-card/FileUploadCard.svelte index a090002ac5..0d56fa8d83 100644 --- a/libs/web-components/src/components/file-upload-card/FileUploadCard.svelte +++ b/libs/web-components/src/components/file-upload-card/FileUploadCard.svelte @@ -5,11 +5,17 @@ // Public + /** @required The name of the uploaded file to display. */ export let filename: string; + /** @required The file size in bytes. Displayed in a human-readable format (KB, MB). */ export let size: number; + /** The MIME type of the file. Used to determine the file type icon. */ export let type: string = ""; + /** Upload progress percentage from 0-100. Use -1 to indicate upload is complete. */ export let progress: number = -1; + /** Error message to display. When set, the card shows an error state with a cancel button. */ export let error: string = ""; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; export let version: "1" | "2" = "1"; diff --git a/libs/web-components/src/components/file-upload-input/FileUploadInput.svelte b/libs/web-components/src/components/file-upload-input/FileUploadInput.svelte index 1e4f796bce..83ed8d1452 100644 --- a/libs/web-components/src/components/file-upload-input/FileUploadInput.svelte +++ b/libs/web-components/src/components/file-upload-input/FileUploadInput.svelte @@ -12,9 +12,13 @@ // Public + /** The input display variant. "dragdrop" shows a drag-and-drop area, "button" shows a simple button. */ export let variant: Variant = "dragdrop"; + /** Accepted file types as a comma-separated list of MIME types or file extensions (e.g., "image/*,.pdf"). */ export let accept: string = "*"; + /** Maximum file size with unit (e.g., "5MB", "100KB", "1GB"). Files exceeding this will be rejected. */ export let maxfilesize: string = "5MB"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; export let version: "1" | "2" = "1"; diff --git a/libs/web-components/src/components/filter-chip/FilterChip.svelte b/libs/web-components/src/components/filter-chip/FilterChip.svelte index 536883300c..ddc4feafa3 100644 --- a/libs/web-components/src/components/filter-chip/FilterChip.svelte +++ b/libs/web-components/src/components/filter-chip/FilterChip.svelte @@ -7,19 +7,29 @@ import { calculateMargin } from "../../common/styling"; import type { GoAIconType } from "../icon/Icon.svelte"; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; // Props + /** Shows an error state. */ export let error: string = "false"; + /** @required Text label of the chip. */ export let content: string; + /** Secondary text displayed in a smaller size before the main content. */ export let secondarytext: string = ""; + /** Icon displayed at the start of the chip. */ export let leadingicon: GoAIconType | null = null; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Accessible label for the filter chip. Defaults to content with 'removable' suffix. */ export let ariaLabel: string = ""; + /** The design system version for styling purposes. */ export let version: "1" | "2" = "1"; // Private variables diff --git a/libs/web-components/src/components/focus-trap/FocusTrap.svelte b/libs/web-components/src/components/focus-trap/FocusTrap.svelte index 07ca2eeca5..5b3d846ec5 100644 --- a/libs/web-components/src/components/focus-trap/FocusTrap.svelte +++ b/libs/web-components/src/components/focus-trap/FocusTrap.svelte @@ -15,9 +15,10 @@ import { findFirstFocusableNode } from "../../common/utils"; // Public - // allow for outside control of whether focus trap should re-focus the first element is open/closed (see Drawer) - export let open: boolean = false; + /** Controls whether the focus trap is active. When true, focuses the first focusable element. */ + export let open: boolean = false; + /** When true, prevents automatically scrolling focused elements into view. */ export let preventScrollIntoView: boolean = false; // Private diff --git a/libs/web-components/src/components/footer-meta-section/FooterMetaSection.svelte b/libs/web-components/src/components/footer-meta-section/FooterMetaSection.svelte index ed0f9e658f..c4a1e7b6d0 100644 --- a/libs/web-components/src/components/footer-meta-section/FooterMetaSection.svelte +++ b/libs/web-components/src/components/footer-meta-section/FooterMetaSection.svelte @@ -4,6 +4,7 @@ import { onMount, tick } from "svelte"; import { getSlottedChildren } from "../../common/utils"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; let rootEl: HTMLElement; diff --git a/libs/web-components/src/components/footer-nav-section/FooterNavSection.svelte b/libs/web-components/src/components/footer-nav-section/FooterNavSection.svelte index 63b67888dc..aaf754bbcb 100644 --- a/libs/web-components/src/components/footer-nav-section/FooterNavSection.svelte +++ b/libs/web-components/src/components/footer-nav-section/FooterNavSection.svelte @@ -3,8 +3,11 @@ diff --git a/libs/web-components/src/components/side-menu/SideMenu.svelte b/libs/web-components/src/components/side-menu/SideMenu.svelte index c3358eae90..26760e664d 100644 --- a/libs/web-components/src/components/side-menu/SideMenu.svelte +++ b/libs/web-components/src/components/side-menu/SideMenu.svelte @@ -6,7 +6,9 @@ import { isUrlMatch, getMatchedLink } from "../../common/urls"; import { SideMenuGroupProps } from "../side-menu-group/SideMenuGroup.svelte"; + /** The design system version for styling purposes. */ export let version: "1" | "2" = "1"; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; let _rootEl: HTMLElement; diff --git a/libs/web-components/src/components/skeleton/Skeleton.svelte b/libs/web-components/src/components/skeleton/Skeleton.svelte index d4d96f3db3..0f101ae3e6 100644 --- a/libs/web-components/src/components/skeleton/Skeleton.svelte +++ b/libs/web-components/src/components/skeleton/Skeleton.svelte @@ -37,16 +37,25 @@ type SkeletonType = (typeof Types)[number]; type SkeletonSize = (typeof Sizes)[number]; + /** Set component maximum width. Currently only used in card skeleton type */ + export let maxwidth: string = "300px"; + /** Size can affect either the height, width or both for different skeleton types. */ export let size: SkeletonSize = "1"; + /** Used within components that contain multiple lines. Currently only used in card skeleton type */ export let linecount: number = 3; + /** @required Reset skeleton shapes to represent your content. */ export let type: SkeletonType; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; - // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; onMount(() => { diff --git a/libs/web-components/src/components/spacer/Spacer.svelte b/libs/web-components/src/components/spacer/Spacer.svelte index 1d3df35de8..154d297cd3 100644 --- a/libs/web-components/src/components/spacer/Spacer.svelte +++ b/libs/web-components/src/components/spacer/Spacer.svelte @@ -4,8 +4,12 @@ import { onMount } from "svelte"; import { injectCss, type Spacing } from "../../common/styling"; + /** Horizontal spacing */ + export let hspacing: Spacing | "fill" = "none"; + /** Vertical spacing */ export let vspacing: Spacing = "none"; + /** Sets the data-testid attribute. Used with ByTestId queries in tests. */ export let testid: string = ""; let rootEl: HTMLElement; diff --git a/libs/web-components/src/components/spinner/Spinner.svelte b/libs/web-components/src/components/spinner/Spinner.svelte index 73f1ec1ad0..1699c0ee92 100644 --- a/libs/web-components/src/components/spinner/Spinner.svelte +++ b/libs/web-components/src/components/spinner/Spinner.svelte @@ -10,12 +10,14 @@ import { tweened } from "svelte/motion"; import { quartOut } from "svelte/easing"; - // required + /** @required Sets the size of the spinner. */ export let size: SpinnerSize; - // optional + /** When true, inverts colors for use on dark backgrounds. */ export let invert: boolean = false; + /** Progress value (0-100). When >= 0, shows a progress spinner instead of infinite. */ export let progress: number = -1; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; let type: SpinnerType = "infinite"; diff --git a/libs/web-components/src/components/tab/Tab.svelte b/libs/web-components/src/components/tab/Tab.svelte index 8f5695c30d..98fa158502 100644 --- a/libs/web-components/src/components/tab/Tab.svelte +++ b/libs/web-components/src/components/tab/Tab.svelte @@ -24,7 +24,9 @@ // Public // ====== + /** The text label for this tab. Can also use the heading slot for custom content. */ export let heading: string = ""; + /** Whether this tab is currently selected/active. */ export let open: boolean = false; export let disabled: boolean = false; export let slug: string = ""; diff --git a/libs/web-components/src/components/table/Table.svelte b/libs/web-components/src/components/table/Table.svelte index f20de3ce54..48c500e508 100644 --- a/libs/web-components/src/components/table/Table.svelte +++ b/libs/web-components/src/components/table/Table.svelte @@ -27,16 +27,26 @@ // Public + /** Width of the table. By default it will fit the enclosed content. */ export let width: string = ""; + /** When true, the table header sticks to the top when scrolling. */ export let stickyheader: string = "false"; + /** When true, alternates row background colors for improved readability. */ export let striped: string = "false"; + /** A relaxed variant of the table with more vertical padding for the cells. */ export let variant: Variant = "normal"; + /** The design system version for styling purposes. */ export let version: VersionType = "1"; + /** Sets the data-testid attribute. Used with ByTestId queries in tests. */ export let testid: string = ""; + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; // Private diff --git a/libs/web-components/src/components/tabs/Tabs.svelte b/libs/web-components/src/components/tabs/Tabs.svelte index b44f8688ad..73f3b1bf33 100644 --- a/libs/web-components/src/components/tabs/Tabs.svelte +++ b/libs/web-components/src/components/tabs/Tabs.svelte @@ -5,8 +5,11 @@ import { clamp, ensureSlotExists, fromBoolean } from "../../common/utils"; import { GoATabProps } from "../tab/Tab.svelte"; - export let initialtab: number = -1; // 1-based + /** The initially active tab (1-based index). If not set, the first tab is active. */ + export let initialtab: number = -1; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** The design system version for styling purposes. */ export let version: "1" | "2" = "1"; export let variant: "default" | "segmented" = "default"; diff --git a/libs/web-components/src/components/temporary-notification/TemporaryNotification.svelte b/libs/web-components/src/components/temporary-notification/TemporaryNotification.svelte index 93027b3867..e7eaca1a62 100644 --- a/libs/web-components/src/components/temporary-notification/TemporaryNotification.svelte +++ b/libs/web-components/src/components/temporary-notification/TemporaryNotification.svelte @@ -25,12 +25,20 @@ | "progress"; // Props + + /** The notification message text to display. */ export let message: string = ""; + /** The notification type which determines the visual style and icon. */ export let type: TemporaryNotificationType = "basic"; + /** Progress value from 0-100. Use -1 to hide the progress bar. Only applies when type is "progress". */ export let progress: number = -1; // -1 = hidden, 0-100 = show + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Text for the optional action button. When provided, displays a clickable link button. */ export let actionText: string = ""; + /** Controls whether the notification is visible. */ export let visible: boolean = true; + /** Direction the notification animates from when appearing or disappearing. */ export let animationDirection: TemporaryNotificationAnimationDirection = "down"; // Icon size for success/failure icons diff --git a/libs/web-components/src/components/text-area/TextArea.svelte b/libs/web-components/src/components/text-area/TextArea.svelte index 8cc282ba6e..f52acbae03 100644 --- a/libs/web-components/src/components/text-area/TextArea.svelte +++ b/libs/web-components/src/components/text-area/TextArea.svelte @@ -27,19 +27,34 @@ FieldsetResetFieldsMsg, } from "../../types/relay-types"; + /** Name of the input value that is received in the _change event */ + export let name: string; + /** Bound to value */ export let value: string = ""; + /** Text displayed within the input when no value is set. */ export let placeholder: string = ""; + /** Set the number of rows. */ export let rows: number = 3; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Width of the text area. */ export let width: string = "100%"; // 100% is default, the lower value of width and maxwidth wins + /** Maximum width of the text area */ export let maxwidth: string = "60ch"; // 60ch is default, the lower value wins b/w width and maxwidth + /** Sets the input to an error state */ export let error: string = "false"; + /** Sets the input to a read only state. */ export let readonly: string = "false"; + /** Sets the input to a disabled state. Use [attr.disabled] with [formControl] */ export let disabled: string = "false"; + /** Defines how the text will be translated for the screen reader. If not specified it will fall back to the name. */ export let arialabel: string = ""; + /** Counting interval for characters or words, specifying whether to count every character or word. */ export let countby: "character" | "word" | "" = ""; + /** Maximum number of characters or words allowed */ export let maxcount: number = -1; + /** Specifies the autocomplete attribute for the textarea input. */ export let autocomplete: string = ""; // version @@ -53,9 +68,13 @@ export let size: SizeType = "default"; // margin + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; let _error = false; diff --git a/libs/web-components/src/components/text/Text.svelte b/libs/web-components/src/components/text/Text.svelte index ca438f7c3b..c3bebc5100 100644 --- a/libs/web-components/src/components/text/Text.svelte +++ b/libs/web-components/src/components/text/Text.svelte @@ -22,14 +22,22 @@ import { calculateMargin, Spacing } from "../../common/styling"; import { style, styles } from "../../common/utils"; + /** The HTML element to render. Use semantic elements like 'h1'-'h6' for headings. */ export let as: TextElement | HeadingElement = "div"; + /** Sets the max width. */ export let maxWidth: string | "none" = "65ch"; + /** Overrides the text size. */ export let size: Size | undefined = undefined; + /** Sets the text colour. */ export let color: "primary" | "secondary" = "primary"; + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; let _marginBottom: Spacing = null; diff --git a/libs/web-components/src/components/tooltip/Tooltip.svelte b/libs/web-components/src/components/tooltip/Tooltip.svelte index 4047d76e41..6046218d5f 100644 --- a/libs/web-components/src/components/tooltip/Tooltip.svelte +++ b/libs/web-components/src/components/tooltip/Tooltip.svelte @@ -8,14 +8,23 @@ // Public + /** The content of the tooltip. */ export let content = ""; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Position with respect to the child element. */ export let position: Position = "top"; + /** Horizontal alignment to the child element. */ export let halign: Alignment = "center"; + /** Sets the maximum width of the tooltip. Must use 'px' unit. */ export let maxwidth: string = ""; + /** Top margin. */ export let mt: Spacing = null; + /** Right margin. */ export let mr: Spacing = null; + /** Bottom margin. */ export let mb: Spacing = null; + /** Left margin. */ export let ml: Spacing = null; // Types diff --git a/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte b/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte index 774e5bb0b3..785627189e 100644 --- a/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte +++ b/libs/web-components/src/components/work-side-menu/WorkSideMenu.svelte @@ -24,13 +24,20 @@ // Public // ****** + /** @required The application name displayed in the header. */ export let heading: string; + /** @required URL for the header link. Clicking the logo/heading navigates to this URL. */ export let url: string; // optional + + /** Controls whether the side menu is expanded or collapsed. */ export let open = false; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** User's name displayed in the profile section. */ export let userName: string = ""; + /** Secondary text displayed below the user's name, such as role or email. */ export let userSecondaryText: string = ""; // ******* diff --git a/libs/web-components/src/components/work-side-menu/WorkSideMenuItem.svelte b/libs/web-components/src/components/work-side-menu/WorkSideMenuItem.svelte index aff03e5425..c60d610a1e 100644 --- a/libs/web-components/src/components/work-side-menu/WorkSideMenuItem.svelte +++ b/libs/web-components/src/components/work-side-menu/WorkSideMenuItem.svelte @@ -10,15 +10,23 @@ // Public // ****** + /** @required The text label displayed for the menu item. */ export let label: string; + /** @required The URL the menu item links to. */ export let url: string; // optional + /** Badge text displayed alongside the menu item (e.g., notification count). */ export let badge: string = ""; + /** When true, indicates this is the currently active menu item. */ export let current: boolean = false; + /** When true, displays a divider line above this menu item. */ export let divider: boolean = false; + /** Icon displayed before the menu item label. */ export let icon: string = ""; + /** Sets a data-testid attribute for automated testing. */ export let testid: string = ""; + /** Sets the visual style of the badge. Use "emergency" for urgent items, "success" for positive status. */ export let type: WorkSideMenuItemType = "normal"; // ******* From 0105c780dcc74569d62df216af067c26dcd5e00a Mon Sep 17 00:00:00 2001 From: Thomas Jeffery Date: Wed, 14 Jan 2026 16:35:06 -0700 Subject: [PATCH 13/26] Fix JSDoc comments based on PR review feedback - Remove @required tags from props without validateRequired() calls - Fix overstated "form submission" claims to "change events" - Remove incorrect "Auto-generated" claim from Accordion id prop - Fix "Reset skeleton shapes" to "Sets the skeleton shape" Addresses feedback from initial review --- libs/web-components/src/components/accordion/Accordion.svelte | 2 +- libs/web-components/src/components/badge/Badge.svelte | 2 +- .../src/components/button-group/ButtonGroup.svelte | 3 +-- libs/web-components/src/components/calendar/Calendar.svelte | 2 +- .../web-components/src/components/card-image/CardImage.svelte | 2 +- .../src/components/checkbox-list/CheckboxList.svelte | 2 +- libs/web-components/src/components/drawer/Drawer.svelte | 4 ++-- libs/web-components/src/components/input/Input.svelte | 2 +- .../src/components/radio-group/RadioGroup.svelte | 2 +- .../web-components/src/components/radio-item/RadioItem.svelte | 2 +- .../src/components/side-menu-group/SideMenuGroup.svelte | 2 +- libs/web-components/src/components/skeleton/Skeleton.svelte | 2 +- libs/web-components/src/components/spinner/Spinner.svelte | 2 +- .../src/components/work-side-menu/WorkSideMenu.svelte | 4 ++-- .../src/components/work-side-menu/WorkSideMenuItem.svelte | 4 ++-- 15 files changed, 18 insertions(+), 19 deletions(-) diff --git a/libs/web-components/src/components/accordion/Accordion.svelte b/libs/web-components/src/components/accordion/Accordion.svelte index 667345e412..7b8482cabd 100644 --- a/libs/web-components/src/components/accordion/Accordion.svelte +++ b/libs/web-components/src/components/accordion/Accordion.svelte @@ -34,7 +34,7 @@ export let secondarytext: string = ""; /** Sets the heading size of the accordion container heading. */ export let headingsize: HeadingSize = "small"; - /** Unique identifier for the accordion. Auto-generated if not provided. */ + /** Unique identifier for the accordion. */ export let id: string = ""; /** Sets the maximum width of the accordion. */ export let maxwidth: string = "none"; diff --git a/libs/web-components/src/components/badge/Badge.svelte b/libs/web-components/src/components/badge/Badge.svelte index 024003749a..90927550fc 100644 --- a/libs/web-components/src/components/badge/Badge.svelte +++ b/libs/web-components/src/components/badge/Badge.svelte @@ -71,7 +71,7 @@ type BadgeSize = (typeof badgeSizes)[number]; type BadgeVersion = (typeof versions)[number]; - /** @required Defines the context and colour of the badge. */ + /** Defines the context and colour of the badge. */ export let type: BadgeType; // Optional diff --git a/libs/web-components/src/components/button-group/ButtonGroup.svelte b/libs/web-components/src/components/button-group/ButtonGroup.svelte index bff6fa34ad..671a722dda 100644 --- a/libs/web-components/src/components/button-group/ButtonGroup.svelte +++ b/libs/web-components/src/components/button-group/ButtonGroup.svelte @@ -7,8 +7,7 @@ import { onMount } from "svelte"; import { typeValidator } from "../../common/utils"; - /** @required Positions the button group in the page layout. */ - + /** Positions the button group in the page layout. */ export let alignment: ButtonAlignment = "start"; /** Sets the spacing between buttons in the button group. */ export let gap: Gap = "relaxed"; diff --git a/libs/web-components/src/components/calendar/Calendar.svelte b/libs/web-components/src/components/calendar/Calendar.svelte index 9b2a144026..7e60206708 100644 --- a/libs/web-components/src/components/calendar/Calendar.svelte +++ b/libs/web-components/src/components/calendar/Calendar.svelte @@ -10,7 +10,7 @@ // Public // ****** - /** Name identifier for the calendar, used in form submission and change events. */ + /** Name identifier for the calendar, included in change events. */ export let name: string = ""; /** The currently selected date value in YYYY-MM-DD format. */ export let value: string = ""; diff --git a/libs/web-components/src/components/card-image/CardImage.svelte b/libs/web-components/src/components/card-image/CardImage.svelte index 0834cb696a..1a67f97476 100644 --- a/libs/web-components/src/components/card-image/CardImage.svelte +++ b/libs/web-components/src/components/card-image/CardImage.svelte @@ -2,7 +2,7 @@ + + Goab Component Playground + + + diff --git a/apps/prs/web/src/routes/3279.svelte b/apps/prs/web/src/routes/3279.svelte new file mode 100644 index 0000000000..8b2448c4de --- /dev/null +++ b/apps/prs/web/src/routes/3279.svelte @@ -0,0 +1,36 @@ + + +
+

Issue 3279

+

Two filter chips (v1 + v2) with click counts.

+ +
+ +
+ +
+ +
+
+ + diff --git a/libs/web-components/src/components/filter-chip/FilterChip.svelte b/libs/web-components/src/components/filter-chip/FilterChip.svelte index b79e5c30dc..8b19ca8f55 100644 --- a/libs/web-components/src/components/filter-chip/FilterChip.svelte +++ b/libs/web-components/src/components/filter-chip/FilterChip.svelte @@ -46,9 +46,7 @@ // Event handlers function onDelete(e: Event) { - el.dispatchEvent( - new CustomEvent("_click", { composed: true, bubbles: true }), - ); + el.dispatchEvent(new CustomEvent("_click", { composed: true, bubbles: true })); e.stopPropagation(); } @@ -94,7 +92,7 @@ Date: Thu, 22 Jan 2026 15:15:39 -0700 Subject: [PATCH 18/26] feat(#3241): Create experimental wrappers for v2 --- apps/prs/angular/src/app/app.component.html | 1 + apps/prs/angular/src/app/app.routes.ts | 2 + .../features/feat3241/feat3241.component.html | 621 +++++++++++ .../features/feat3241/feat3241.component.ts | 223 ++++ apps/prs/react/src/app/app.tsx | 2 +- apps/prs/react/src/main.tsx | 6 +- .../react/src/routes/features/feat3102.tsx | 13 +- .../react/src/routes/features/feat3241.tsx | 961 ++++++++++++++++++ .../src/experimental/.eslintrc.json | 25 + .../src/experimental/badge/badge.spec.ts | 111 ++ .../src/experimental/badge/badge.ts | 75 ++ .../src/experimental/base.component.ts | 176 ++++ .../src/experimental/button/button.spec.ts | 98 ++ .../src/experimental/button/button.ts | 85 ++ .../experimental/calendar/calendar.spec.ts | 96 ++ .../src/experimental/calendar/calendar.ts | 67 ++ .../src/experimental/callout/callout.spec.ts | 80 ++ .../src/experimental/callout/callout.ts | 68 ++ .../experimental/checkbox/checkbox.spec.ts | 263 +++++ .../src/experimental/checkbox/checkbox.ts | 130 +++ .../date-picker/date-picker.spec.ts | 102 ++ .../experimental/date-picker/date-picker.ts | 139 +++ .../src/experimental/drawer/drawer.spec.ts | 71 ++ .../src/experimental/drawer/drawer.ts | 77 ++ .../dropdown-item/dropdown-item.spec.ts | 17 + .../dropdown-item/dropdown-item.ts | 47 + .../experimental/dropdown/dropdown.spec.ts | 256 +++++ .../src/experimental/dropdown/dropdown.ts | 117 +++ .../file-upload-card/file-upload-card.spec.ts | 127 +++ .../file-upload-card/file-upload-card.ts | 68 ++ .../file-upload-input.spec.ts | 87 ++ .../file-upload-input/file-upload-input.ts | 66 ++ .../filter-chip/filter-chip.spec.ts | 85 ++ .../experimental/filter-chip/filter-chip.ts | 67 ++ .../footer-meta-section.spec.ts | 44 + .../footer-meta-section.ts | 39 + .../footer-nav-section.spec.ts | 56 + .../footer-nav-section/footer-nav-section.ts | 46 + .../src/experimental/footer/footer.spec.ts | 47 + .../src/experimental/footer/footer.ts | 47 + .../experimental/form-item/form-item-slot.ts | 16 + .../experimental/form-item/form-item.spec.ts | 109 ++ .../src/experimental/form-item/form-item.ts | 79 ++ .../src/experimental/index.ts | 29 + .../src/experimental/input/input.spec.ts | 370 +++++++ .../src/experimental/input/input.ts | 218 ++++ .../src/experimental/link/link.spec.ts | 69 ++ .../src/experimental/link/link.ts | 66 ++ .../src/experimental/modal/modal.spec.ts | 87 ++ .../src/experimental/modal/modal.ts | 92 ++ .../notification/notification.spec.ts | 67 ++ .../experimental/notification/notification.ts | 63 ++ .../pagination/pagination.spec.ts | 73 ++ .../src/experimental/pagination/pagination.ts | 66 ++ .../radio-group/radio-group.spec.ts | 243 +++++ .../experimental/radio-group/radio-group.ts | 88 ++ .../radio-item/radio-item.spec.ts | 49 + .../src/experimental/radio-item/radio-item.ts | 88 ++ .../side-menu-group/side-menu-group.spec.ts | 41 + .../side-menu-group/side-menu-group.ts | 43 + .../side-menu-heading.spec.ts | 40 + .../side-menu-heading/side-menu-heading.ts | 39 + .../experimental/side-menu/side-menu.spec.ts | 39 + .../src/experimental/side-menu/side-menu.ts | 32 + .../src/experimental/table/table.spec.ts | 107 ++ .../src/experimental/table/table.ts | 64 ++ .../src/experimental/tabs/tabs.spec.ts | 62 ++ .../src/experimental/tabs/tabs.ts | 54 + .../experimental/textarea/textarea.spec.ts | 182 ++++ .../src/experimental/textarea/textarea.ts | 122 +++ libs/common/src/index.ts | 1 + libs/common/src/lib/experimental/common.ts | 42 + .../src/experimental/badge/badge.spec.tsx | 153 +++ .../src/experimental/badge/badge.tsx | 85 ++ .../src/experimental/button/button.spec.tsx | 160 +++ .../src/experimental/button/button.tsx | 101 ++ .../experimental/calendar/calendar.spec.tsx | 62 ++ .../src/experimental/calendar/calendar.tsx | 81 ++ .../src/experimental/callout/callout.spec.tsx | 79 ++ .../src/experimental/callout/callout.tsx | 67 ++ .../experimental/checkbox/checkbox.spec.tsx | 161 +++ .../src/experimental/checkbox/checkbox.tsx | 123 +++ .../date-picker/date-picker.spec.tsx | 91 ++ .../experimental/date-picker/date-picker.tsx | 124 +++ .../src/experimental/drawer/drawer.spec.tsx | 92 ++ .../src/experimental/drawer/drawer.tsx | 75 ++ .../experimental/dropdown/dropdown-item.tsx | 58 ++ .../experimental/dropdown/dropdown.spec.tsx | 132 +++ .../src/experimental/dropdown/dropdown.tsx | 140 +++ .../file-upload-card.spec.tsx | 120 +++ .../file-upload-card/file-upload-card.tsx | 72 ++ .../file-upload-input.spec.tsx | 46 + .../file-upload-input/file-upload-input.tsx | 66 ++ .../filter-chip/filter-chip.spec.tsx | 85 ++ .../experimental/filter-chip/filter-chip.tsx | 78 ++ .../footer-meta-section.spec.tsx | 24 + .../footer-meta-section.tsx | 37 + .../footer-nav-section.spec.tsx | 23 + .../footer-nav-section/footer-nav-section.tsx | 41 + .../src/experimental/footer/footer.spec.tsx | 22 + .../src/experimental/footer/footer.tsx | 40 + .../experimental/form-item/form-item.spec.tsx | 73 ++ .../src/experimental/form-item/form-item.tsx | 83 ++ .../src/experimental/form/form-summary.tsx | 16 - .../src/experimental/form/form.tsx | 100 -- .../src/experimental/index.ts | 31 +- .../src/experimental/input/input.spec.tsx | 211 ++++ .../src/experimental/input/input.tsx | 399 ++++++++ .../src/experimental/link/link.spec.tsx | 52 + .../src/experimental/link/link.tsx | 64 ++ .../src/experimental/modal/modal.spec.tsx | 63 ++ .../src/experimental/modal/modal.tsx | 100 ++ .../notification/notification.spec.tsx | 56 + .../notification/notification.tsx | 84 ++ .../pagination/pagination.spec.tsx | 50 + .../experimental/pagination/pagination.tsx | 77 ++ .../radio-group/radio-group.spec.tsx | 290 ++++++ .../experimental/radio-group/radio-group.tsx | 99 ++ .../src/experimental/radio-group/radio.tsx | 95 ++ .../resizable-panel/ResizablePanel.module.css | 49 - .../resizable-panel/ResizablePanel.tsx | 104 -- .../side-menu-group/side-menu-group.spec.tsx | 21 + .../side-menu-group/side-menu-group.tsx | 56 + .../side-menu-heading.spec.tsx | 14 + .../side-menu-heading/side-menu-heading.tsx | 43 + .../experimental/side-menu/side-menu.spec.tsx | 16 + .../src/experimental/side-menu/side-menu.tsx | 36 + .../table/table-sort-header.spec.tsx | 22 + .../experimental/table/table-sort-header.tsx | 36 + .../src/experimental/table/table.spec.tsx | 50 + .../src/experimental/table/table.tsx | 80 ++ .../src/experimental/tabs/tabs.spec.tsx | 25 + .../src/experimental/tabs/tabs.tsx | 68 ++ .../experimental/textarea/textarea.spec.tsx | 107 ++ .../src/experimental/textarea/textarea.tsx | 126 +++ libs/react-components/src/lib/badge/badge.tsx | 22 +- .../src/lib/button/button.tsx | 11 - .../src/lib/calendar/calendar.tsx | 10 - .../src/lib/callout/callout.tsx | 9 - .../src/lib/checkbox/checkbox.tsx | 11 - .../src/lib/date-picker/date-picker.tsx | 11 - .../src/lib/drawer/drawer.tsx | 18 - .../src/lib/dropdown/dropdown-item.tsx | 19 - .../src/lib/dropdown/dropdown.tsx | 12 - .../lib/file-upload-card/file-upload-card.tsx | 11 - .../file-upload-input/file-upload-input.tsx | 11 - .../src/lib/filter-chip/filter-chip.tsx | 11 - .../footer-meta-section.tsx | 9 - .../footer-nav-section/footer-nav-section.tsx | 9 - .../src/lib/footer/footer.tsx | 9 - .../src/lib/form-item/form-item.tsx | 9 - libs/react-components/src/lib/input/input.tsx | 11 - libs/react-components/src/lib/link/link.tsx | 9 - libs/react-components/src/lib/modal/modal.tsx | 25 - .../src/lib/notification/notification.tsx | 17 - .../src/lib/pagination/pagination.tsx | 18 - .../src/lib/radio-group/radio-group.tsx | 11 - .../src/lib/radio-group/radio.tsx | 23 - .../lib/side-menu-group/side-menu-group.tsx | 15 - .../side-menu-heading/side-menu-heading.tsx | 14 - .../src/lib/side-menu/side-menu.tsx | 13 - .../src/lib/table/table-sort-header.tsx | 9 - libs/react-components/src/lib/table/table.tsx | 17 - libs/react-components/src/lib/tabs/tabs.tsx | 17 - .../src/lib/textarea/textarea.tsx | 11 - 165 files changed, 12405 insertions(+), 679 deletions(-) create mode 100644 apps/prs/angular/src/routes/features/feat3241/feat3241.component.html create mode 100644 apps/prs/angular/src/routes/features/feat3241/feat3241.component.ts create mode 100644 apps/prs/react/src/routes/features/feat3241.tsx create mode 100644 libs/angular-components/src/experimental/.eslintrc.json create mode 100644 libs/angular-components/src/experimental/badge/badge.spec.ts create mode 100644 libs/angular-components/src/experimental/badge/badge.ts create mode 100644 libs/angular-components/src/experimental/base.component.ts create mode 100644 libs/angular-components/src/experimental/button/button.spec.ts create mode 100644 libs/angular-components/src/experimental/button/button.ts create mode 100644 libs/angular-components/src/experimental/calendar/calendar.spec.ts create mode 100644 libs/angular-components/src/experimental/calendar/calendar.ts create mode 100644 libs/angular-components/src/experimental/callout/callout.spec.ts create mode 100644 libs/angular-components/src/experimental/callout/callout.ts create mode 100644 libs/angular-components/src/experimental/checkbox/checkbox.spec.ts create mode 100644 libs/angular-components/src/experimental/checkbox/checkbox.ts create mode 100644 libs/angular-components/src/experimental/date-picker/date-picker.spec.ts create mode 100644 libs/angular-components/src/experimental/date-picker/date-picker.ts create mode 100644 libs/angular-components/src/experimental/drawer/drawer.spec.ts create mode 100644 libs/angular-components/src/experimental/drawer/drawer.ts create mode 100644 libs/angular-components/src/experimental/dropdown-item/dropdown-item.spec.ts create mode 100644 libs/angular-components/src/experimental/dropdown-item/dropdown-item.ts create mode 100644 libs/angular-components/src/experimental/dropdown/dropdown.spec.ts create mode 100644 libs/angular-components/src/experimental/dropdown/dropdown.ts create mode 100644 libs/angular-components/src/experimental/file-upload-card/file-upload-card.spec.ts create mode 100644 libs/angular-components/src/experimental/file-upload-card/file-upload-card.ts create mode 100644 libs/angular-components/src/experimental/file-upload-input/file-upload-input.spec.ts create mode 100644 libs/angular-components/src/experimental/file-upload-input/file-upload-input.ts create mode 100644 libs/angular-components/src/experimental/filter-chip/filter-chip.spec.ts create mode 100644 libs/angular-components/src/experimental/filter-chip/filter-chip.ts create mode 100644 libs/angular-components/src/experimental/footer-meta-section/footer-meta-section.spec.ts create mode 100644 libs/angular-components/src/experimental/footer-meta-section/footer-meta-section.ts create mode 100644 libs/angular-components/src/experimental/footer-nav-section/footer-nav-section.spec.ts create mode 100644 libs/angular-components/src/experimental/footer-nav-section/footer-nav-section.ts create mode 100644 libs/angular-components/src/experimental/footer/footer.spec.ts create mode 100644 libs/angular-components/src/experimental/footer/footer.ts create mode 100644 libs/angular-components/src/experimental/form-item/form-item-slot.ts create mode 100644 libs/angular-components/src/experimental/form-item/form-item.spec.ts create mode 100644 libs/angular-components/src/experimental/form-item/form-item.ts create mode 100644 libs/angular-components/src/experimental/input/input.spec.ts create mode 100644 libs/angular-components/src/experimental/input/input.ts create mode 100644 libs/angular-components/src/experimental/link/link.spec.ts create mode 100644 libs/angular-components/src/experimental/link/link.ts create mode 100644 libs/angular-components/src/experimental/modal/modal.spec.ts create mode 100644 libs/angular-components/src/experimental/modal/modal.ts create mode 100644 libs/angular-components/src/experimental/notification/notification.spec.ts create mode 100644 libs/angular-components/src/experimental/notification/notification.ts create mode 100644 libs/angular-components/src/experimental/pagination/pagination.spec.ts create mode 100644 libs/angular-components/src/experimental/pagination/pagination.ts create mode 100644 libs/angular-components/src/experimental/radio-group/radio-group.spec.ts create mode 100644 libs/angular-components/src/experimental/radio-group/radio-group.ts create mode 100644 libs/angular-components/src/experimental/radio-item/radio-item.spec.ts create mode 100644 libs/angular-components/src/experimental/radio-item/radio-item.ts create mode 100644 libs/angular-components/src/experimental/side-menu-group/side-menu-group.spec.ts create mode 100644 libs/angular-components/src/experimental/side-menu-group/side-menu-group.ts create mode 100644 libs/angular-components/src/experimental/side-menu-heading/side-menu-heading.spec.ts create mode 100644 libs/angular-components/src/experimental/side-menu-heading/side-menu-heading.ts create mode 100644 libs/angular-components/src/experimental/side-menu/side-menu.spec.ts create mode 100644 libs/angular-components/src/experimental/side-menu/side-menu.ts create mode 100644 libs/angular-components/src/experimental/table/table.spec.ts create mode 100644 libs/angular-components/src/experimental/table/table.ts create mode 100644 libs/angular-components/src/experimental/tabs/tabs.spec.ts create mode 100644 libs/angular-components/src/experimental/tabs/tabs.ts create mode 100644 libs/angular-components/src/experimental/textarea/textarea.spec.ts create mode 100644 libs/angular-components/src/experimental/textarea/textarea.ts create mode 100644 libs/common/src/lib/experimental/common.ts create mode 100644 libs/react-components/src/experimental/badge/badge.spec.tsx create mode 100644 libs/react-components/src/experimental/badge/badge.tsx create mode 100644 libs/react-components/src/experimental/button/button.spec.tsx create mode 100644 libs/react-components/src/experimental/button/button.tsx create mode 100644 libs/react-components/src/experimental/calendar/calendar.spec.tsx create mode 100644 libs/react-components/src/experimental/calendar/calendar.tsx create mode 100644 libs/react-components/src/experimental/callout/callout.spec.tsx create mode 100644 libs/react-components/src/experimental/callout/callout.tsx create mode 100644 libs/react-components/src/experimental/checkbox/checkbox.spec.tsx create mode 100644 libs/react-components/src/experimental/checkbox/checkbox.tsx create mode 100644 libs/react-components/src/experimental/date-picker/date-picker.spec.tsx create mode 100644 libs/react-components/src/experimental/date-picker/date-picker.tsx create mode 100644 libs/react-components/src/experimental/drawer/drawer.spec.tsx create mode 100644 libs/react-components/src/experimental/drawer/drawer.tsx create mode 100644 libs/react-components/src/experimental/dropdown/dropdown-item.tsx create mode 100644 libs/react-components/src/experimental/dropdown/dropdown.spec.tsx create mode 100644 libs/react-components/src/experimental/dropdown/dropdown.tsx create mode 100644 libs/react-components/src/experimental/file-upload-card/file-upload-card.spec.tsx create mode 100644 libs/react-components/src/experimental/file-upload-card/file-upload-card.tsx create mode 100644 libs/react-components/src/experimental/file-upload-input/file-upload-input.spec.tsx create mode 100644 libs/react-components/src/experimental/file-upload-input/file-upload-input.tsx create mode 100644 libs/react-components/src/experimental/filter-chip/filter-chip.spec.tsx create mode 100644 libs/react-components/src/experimental/filter-chip/filter-chip.tsx create mode 100644 libs/react-components/src/experimental/footer-meta-section/footer-meta-section.spec.tsx create mode 100644 libs/react-components/src/experimental/footer-meta-section/footer-meta-section.tsx create mode 100644 libs/react-components/src/experimental/footer-nav-section/footer-nav-section.spec.tsx create mode 100644 libs/react-components/src/experimental/footer-nav-section/footer-nav-section.tsx create mode 100644 libs/react-components/src/experimental/footer/footer.spec.tsx create mode 100644 libs/react-components/src/experimental/footer/footer.tsx create mode 100644 libs/react-components/src/experimental/form-item/form-item.spec.tsx create mode 100644 libs/react-components/src/experimental/form-item/form-item.tsx delete mode 100644 libs/react-components/src/experimental/form/form-summary.tsx delete mode 100644 libs/react-components/src/experimental/form/form.tsx create mode 100644 libs/react-components/src/experimental/input/input.spec.tsx create mode 100644 libs/react-components/src/experimental/input/input.tsx create mode 100644 libs/react-components/src/experimental/link/link.spec.tsx create mode 100644 libs/react-components/src/experimental/link/link.tsx create mode 100644 libs/react-components/src/experimental/modal/modal.spec.tsx create mode 100644 libs/react-components/src/experimental/modal/modal.tsx create mode 100644 libs/react-components/src/experimental/notification/notification.spec.tsx create mode 100644 libs/react-components/src/experimental/notification/notification.tsx create mode 100644 libs/react-components/src/experimental/pagination/pagination.spec.tsx create mode 100644 libs/react-components/src/experimental/pagination/pagination.tsx create mode 100644 libs/react-components/src/experimental/radio-group/radio-group.spec.tsx create mode 100644 libs/react-components/src/experimental/radio-group/radio-group.tsx create mode 100644 libs/react-components/src/experimental/radio-group/radio.tsx delete mode 100644 libs/react-components/src/experimental/resizable-panel/ResizablePanel.module.css delete mode 100644 libs/react-components/src/experimental/resizable-panel/ResizablePanel.tsx create mode 100644 libs/react-components/src/experimental/side-menu-group/side-menu-group.spec.tsx create mode 100644 libs/react-components/src/experimental/side-menu-group/side-menu-group.tsx create mode 100644 libs/react-components/src/experimental/side-menu-heading/side-menu-heading.spec.tsx create mode 100644 libs/react-components/src/experimental/side-menu-heading/side-menu-heading.tsx create mode 100644 libs/react-components/src/experimental/side-menu/side-menu.spec.tsx create mode 100644 libs/react-components/src/experimental/side-menu/side-menu.tsx create mode 100644 libs/react-components/src/experimental/table/table-sort-header.spec.tsx create mode 100644 libs/react-components/src/experimental/table/table-sort-header.tsx create mode 100644 libs/react-components/src/experimental/table/table.spec.tsx create mode 100644 libs/react-components/src/experimental/table/table.tsx create mode 100644 libs/react-components/src/experimental/tabs/tabs.spec.tsx create mode 100644 libs/react-components/src/experimental/tabs/tabs.tsx create mode 100644 libs/react-components/src/experimental/textarea/textarea.spec.tsx create mode 100644 libs/react-components/src/experimental/textarea/textarea.tsx diff --git a/apps/prs/angular/src/app/app.component.html b/apps/prs/angular/src/app/app.component.html index 1019a8f17f..28f336b485 100644 --- a/apps/prs/angular/src/app/app.component.html +++ b/apps/prs/angular/src/app/app.component.html @@ -76,6 +76,7 @@ 3306 1908 2609 + 3241 v2 header icons 3137 1908 diff --git a/apps/prs/angular/src/app/app.routes.ts b/apps/prs/angular/src/app/app.routes.ts index c76ec93657..22f27b9026 100644 --- a/apps/prs/angular/src/app/app.routes.ts +++ b/apps/prs/angular/src/app/app.routes.ts @@ -58,6 +58,7 @@ import { Feat2829Component } from "../routes/features/feat2829/feat2829.componen import { Feat3102Component } from "../routes/features/feat3102/feat3102.component"; import { Feat1908Component } from "../routes/features/feat1908/feat1908.component"; import { Feat2609Component } from "../routes/features/feat2609/feat2609.component"; +import { Feat3241Component } from "../routes/features/feat3241/feat3241.component"; import { FeatV2IconsComponent } from "../routes/features/featV2Icons/feat-v2-icons.component"; import { Feat3137Component } from "../routes/features/feat3137/feat3137.component"; import { Feat3306Component } from "../routes/features/feat3306/feat3306.component"; @@ -122,6 +123,7 @@ export const appRoutes: Route[] = [ { path: "features/2829", component: Feat2829Component }, { path: "features/3102", component: Feat3102Component }, { path: "features/2609", component: Feat2609Component }, + { path: "features/3241", component: Feat3241Component }, { path: "features/v2-icons", component: FeatV2IconsComponent }, { path: "features/3137", component: Feat3137Component }, { path: "features/1908", component: Feat1908Component }, diff --git a/apps/prs/angular/src/routes/features/feat3241/feat3241.component.html b/apps/prs/angular/src/routes/features/feat3241/feat3241.component.html new file mode 100644 index 0000000000..81e4724bd3 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3241/feat3241.component.html @@ -0,0 +1,621 @@ +Feat 3241 - Experimental wrapper checks + + Side-by-side comparisons of Goabx wrappers and the matching Goab components. + + + Also implements all new properties created for Goabx components + + +App Footer + +
+ GoabxAppFooter + + + Arts and culture + + + Privacy + + +
+
+ GoabAppFooter + + + Arts and culture + + + Privacy + + +
+
+ +Badge + +
+ GoabxBadge + + Size = large + + Emphasis = subtle + + New type property value + +
+
+ GoabBadge + + Type property value that no longer exists + +
+
+ +Button + +
+ GoabxButton + Goabx action +
+
+ GoabButton + Goab action +
+
+ +Calendar + +
+ GoabxCalendar + +
+
+ GoabCalendar + +
+
+ +Callout + +
+ GoabxCallout + + Experimental callout content. + + Emphasis = high + + Experimental callout content + +
+
+ GoabCallout + + Standard callout content. + +
+
+ +Datepicker + +
+ GoabxDatepicker + +
+
+ GoabDatepicker + +
+
+ +Form Item and Input + +
+ GoabxFormItem + GoabxInput + + + + Form Item - Input type = text-input + + + + Input - Size = compact + + + +
+
+ GoabFormItem + GoabInput + + + +
+
+ +Checkbox + +
+ GoabxCheckbox + + Size = compact + +
+
+ GoabCheckbox + +
+
+ +Dropdown and Items + +
+ GoabxDropdown + + + + + + + + Size = compact + + + + + + + +
+
+ GoabDropdown + + + + + + + +
+
+ +Textarea + +
+ GoabxTextArea + + + + Text Area - Size = compact + + + +
+
+ GoabTextArea + + + +
+
+ +Radio Group and Items + +
+ GoabxRadioGroup + + + + + + + Radio Group - Size = compact + Radio Item - Compact = true + + + + + + +
+
+ GoabRadioGroup + + + + + + +
+
+ +Filter Chip + +
+ GoabxFilterChip w/ Secondary Text + + Leading Icon + + Clicks: {{ goabxFilterClicks }} +
+
+ GoabFilterChip + + Clicks: {{ goabFilterClicks }} +
+
+ +Link + +
+ GoabxLink + + Goabx link + + Color = dark + + Goabx link + + Size = large + + Goabx link + +
+
+ GoabLink + + Goab link + +
+
+ +Notification + +
+ GoabxNotification + + Goabx notification content. + + Emphasis = low + + Goabx notification content. + + Compact = true + + Goabx notification content. + + Status: {{ goabxNotificationStatus }} +
+
+ GoabNotification + + Goab notification content. + + Status: {{ goabNotificationStatus }} +
+
+ +Pagination + +
+ GoabxPagination + + Current page: {{ goabxPage }} +
+
+ GoabPagination + + Current page: {{ goabPage }} +
+
+ +Tabs + +
+ GoabxTabs + + + Goabx tab one content. + + + Goabx tab two content. + + + Goabx tab three content. + + + Selected tab: {{ goabxTab }} +
+
+ GoabTabs + + + Goab tab one content. + + + Goab tab two content. + + + Goab tab three content. + + + Selected tab: {{ goabTab }} +
+
+ +Table + +
+ GoabxTable + + + + Service + Status + + + + + Payments + Active + + + Reporting + Pending + + + + GoabxTable - Striped = true + + + + Service + Status + + + + + Payments + Active + + + Reporting + Pending + + + +
+
+ GoabTable + + + + Service + Status + + + + + Payments + Active + + + Reporting + Pending + + + +
+
+ +File Upload Input + +
+ GoabxFileUploadInput + + + +
+
+ GoabFileUploadInput + + + +
+
+ +File Upload Card + +
+ GoabxFileUploadCard + +
+
+ GoabFileUploadCard + +
+
+ +Drawer + +
+ GoabxDrawer + + Close + + Open Goabx drawer + + Goabx drawer content. + +
+
+ GoabDrawer + + Close + + Open Goab drawer + + Goab drawer content. + +
+
+ +Modal + +
+ GoabxModal + + + Cancel + Confirm + + + Open Goabx modal + + Goabx modal content. + +
+
+ GoabModal + + + Cancel + Confirm + + + Open Goab modal + + Goab modal content. + +
+
+ +Side Menu + +
+ GoabxSideMenu + + + + + + Goabx navigation + + + Goabx home + Goabx settings + + +
+
+ GoabSideMenu + + + + + + Goab navigation + + + Goab home + Goab settings + + +
+
diff --git a/apps/prs/angular/src/routes/features/feat3241/feat3241.component.ts b/apps/prs/angular/src/routes/features/feat3241/feat3241.component.ts new file mode 100644 index 0000000000..db661b5cf9 --- /dev/null +++ b/apps/prs/angular/src/routes/features/feat3241/feat3241.component.ts @@ -0,0 +1,223 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { + GoabAppFooter, + GoabAppFooterMetaSection, + GoabAppFooterNavSection, + GoabBadge, + GoabGrid, + GoabButton, + GoabButtonGroup, + GoabCallout, + GoabCheckbox, + GoabDrawer, + GoabDropdown, + GoabDropdownItem, + GoabFileUploadCard, + GoabFileUploadInput, + GoabFilterChip, + GoabFormItem, + GoabInput, + GoabLink, + GoabModal, + GoabNotification, + GoabPagination, + GoabRadioGroup, + GoabRadioItem, + GoabSideMenu, + GoabSideMenuGroup, + GoabSideMenuHeading, + GoabTab, + GoabTable, + GoabTabs, + GoabText, + GoabTextArea, + GoabxAppFooter, + GoabxAppFooterMetaSection, + GoabxAppFooterNavSection, + GoabxBadge, + GoabxButton, + GoabxCallout, + GoabxCheckbox, + GoabxDrawer, + GoabxDropdown, + GoabxDropdownItem, + GoabxFileUploadCard, + GoabxFileUploadInput, + GoabxFilterChip, + GoabxFormItem, + GoabxInput, + GoabxLink, + GoabxModal, + GoabxNotification, + GoabxPagination, + GoabxRadioGroup, + GoabxRadioItem, + GoabxSideMenu, + GoabxSideMenuGroup, + GoabxSideMenuHeading, + GoabxTable, + GoabxTabs, + GoabxTextArea, + GoabDatePicker, + GoabCalendar, + GoabxCalendar, + GoabxDatePicker, +} from "@abgov/angular-components"; +import { + GoabPaginationOnChangeDetail, + GoabTabsOnChangeDetail, +} from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + selector: "abgov-feat3241", + templateUrl: "./feat3241.component.html", + imports: [ + CommonModule, + GoabAppFooter, + GoabAppFooterMetaSection, + GoabAppFooterNavSection, + GoabBadge, + GoabGrid, + GoabButton, + GoabButtonGroup, + GoabCalendar, + GoabCallout, + GoabCheckbox, + GoabDatePicker, + GoabDrawer, + GoabDropdown, + GoabDropdownItem, + GoabFileUploadCard, + GoabFileUploadInput, + GoabFilterChip, + GoabFormItem, + GoabInput, + GoabLink, + GoabModal, + GoabNotification, + GoabPagination, + GoabRadioGroup, + GoabRadioItem, + GoabSideMenu, + GoabSideMenuGroup, + GoabSideMenuHeading, + GoabTab, + GoabTable, + GoabTabs, + GoabText, + GoabTextArea, + GoabxAppFooter, + GoabxAppFooterMetaSection, + GoabxAppFooterNavSection, + GoabxBadge, + GoabxButton, + GoabxCalendar, + GoabxCallout, + GoabxCheckbox, + GoabxDatePicker, + GoabxDrawer, + GoabxDropdown, + GoabxDropdownItem, + GoabxFileUploadCard, + GoabxFileUploadInput, + GoabxFilterChip, + GoabxFormItem, + GoabxInput, + GoabxLink, + GoabxModal, + GoabxNotification, + GoabxPagination, + GoabxRadioGroup, + GoabxRadioItem, + GoabxSideMenu, + GoabxSideMenuGroup, + GoabxSideMenuHeading, + GoabxTable, + GoabxTabs, + GoabxTextArea, + ], +}) +export class Feat3241Component { + goabxDrawerOpen = false; + goabDrawerOpen = false; + goabxModalOpen = false; + goabModalOpen = false; + + goabxPage = 1; + goabPage = 1; + + goabxTab = 1; + goabTab = 1; + + goabxFilterClicks = 0; + goabFilterClicks = 0; + + goabxNotificationStatus = "No dismiss action yet."; + goabNotificationStatus = "No dismiss action yet."; + + openGoabxDrawer() { + this.goabxDrawerOpen = true; + } + + closeGoabxDrawer() { + this.goabxDrawerOpen = false; + } + + openGoabDrawer() { + this.goabDrawerOpen = true; + } + + closeGoabDrawer() { + this.goabDrawerOpen = false; + } + + openGoabxModal() { + this.goabxModalOpen = true; + } + + closeGoabxModal() { + this.goabxModalOpen = false; + } + + openGoabModal() { + this.goabModalOpen = true; + } + + closeGoabModal() { + this.goabModalOpen = false; + } + + onGoabxPaginationChange(detail: GoabPaginationOnChangeDetail) { + this.goabxPage = detail.page; + } + + onGoabPaginationChange(detail: GoabPaginationOnChangeDetail) { + this.goabPage = detail.page; + } + + onGoabxTabsChange(detail: GoabTabsOnChangeDetail) { + this.goabxTab = detail.tab; + } + + onGoabTabsChange(detail: GoabTabsOnChangeDetail) { + this.goabTab = detail.tab; + } + + onGoabxFilterClick() { + this.goabxFilterClicks += 1; + } + + onGoabFilterClick() { + this.goabFilterClicks += 1; + } + + onGoabxNotificationDismiss() { + this.goabxNotificationStatus = "Goabx notification dismissed."; + } + + onGoabNotificationDismiss() { + this.goabNotificationStatus = "Goab notification dismissed."; + } +} diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx index fe37c5b9b2..363390b363 100644 --- a/apps/prs/react/src/app/app.tsx +++ b/apps/prs/react/src/app/app.tsx @@ -84,13 +84,13 @@ export function App() { 2829 Modal ARIA Live Region 2877 Badge Types and Custom Icon 3102 MenuButton Width + 3241 V2 Experimental Wrappers v2 header icons 3137 Work Side Menu Group 3306 Custom slug value for tabs
A - B diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx index 71bdaf04bd..8c70bfc64a 100644 --- a/apps/prs/react/src/main.tsx +++ b/apps/prs/react/src/main.tsx @@ -55,14 +55,15 @@ import { Feat2267Route } from "./routes/features/feat2267"; import { Feat2440Route } from "./routes/features/feat2440"; import { Feat2492Route } from "./routes/features/feat2492"; import { Feat2609Route } from "./routes/features/feat2609"; +import { Feat2611Route } from "./routes/features/feat2611"; import Feat2611TabsDisabledRoute from "./routes/features/feat2611-tabs-disabled"; import { Feat2682Route } from "./routes/features/feat2682"; import { Feat2722Route } from "./routes/features/feat2722"; import { Feat2730Route } from "./routes/features/feat2730"; import { Feat2829Route } from "./routes/features/feat2829"; import { Feat2877Route } from "./routes/features/feat2877"; -import Feat3102Route from "./routes/features/feat3102"; -import { Feat2611Route } from "./routes/features/feat2611"; +import { Feat3102Route } from "./routes/features/feat3102"; +import { Feat3241Route } from "./routes/features/feat3241"; import { FeatV2IconsRoute } from "./routes/features/featV2Icons"; import { Feat3137Route } from "./routes/features/feat3137"; import Feat3306Route from "./routes/features/feat3306"; @@ -136,6 +137,7 @@ root.render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/prs/react/src/routes/features/feat3102.tsx b/apps/prs/react/src/routes/features/feat3102.tsx index 0658979538..e93ce213a1 100644 --- a/apps/prs/react/src/routes/features/feat3102.tsx +++ b/apps/prs/react/src/routes/features/feat3102.tsx @@ -7,7 +7,7 @@ import { } from "@abgov/react-components"; import type { GoabMenuButtonOnActionDetail } from "@abgov/ui-components-common"; -export default function TestComponent() { +export function Feat3102Route() { const onAction = (detail: GoabMenuButtonOnActionDetail) => { console.log(detail); }; @@ -25,23 +25,16 @@ export default function TestComponent() { {" "} - Allow icon to be set on MenuButton - - All the MenuButton to have a leading icon set - + All the MenuButton to have a leading icon set - + - ); } diff --git a/apps/prs/react/src/routes/features/feat3241.tsx b/apps/prs/react/src/routes/features/feat3241.tsx new file mode 100644 index 0000000000..c2ebaefe36 --- /dev/null +++ b/apps/prs/react/src/routes/features/feat3241.tsx @@ -0,0 +1,961 @@ +import { useState } from "react"; +import { + GoabAppFooter, + GoabAppFooterMetaSection, + GoabAppFooterNavSection, + GoabBadge, + GoabButton, + GoabButtonGroup, + GoabCalendar, + GoabCallout, + GoabCheckbox, + GoabDatePicker, + GoabDrawer, + GoabDropdown, + GoabDropdownItem, + GoabFileUploadCard, + GoabFileUploadInput, + GoabFilterChip, + GoabFormItem, + GoabGrid, + GoabInput, + GoabLink, + GoabModal, + GoabNotification, + GoabPagination, + GoabRadioGroup, + GoabRadioItem, + GoabSideMenu, + GoabSideMenuGroup, + GoabSideMenuHeading, + GoabTab, + GoabTable, + GoabTabs, + GoabText, + GoabTextArea, +} from "@abgov/react-components"; +import { + GoabxAppFooter, + GoabxAppFooterMetaSection, + GoabxAppFooterNavSection, + GoabxBadge, + GoabxButton, + GoabxCalendar, + GoabxCallout, + GoabxCheckbox, + GoabxDatePicker, + GoabxDrawer, + GoabxDropdown, + GoabxDropdownItem, + GoabxFileUploadCard, + GoabxFileUploadInput, + GoabxFilterChip, + GoabxFormItem, + GoabxInput, + GoabxLink, + GoabxModal, + GoabxNotification, + GoabxPagination, + GoabxRadioGroup, + GoabxRadioItem, + GoabxSideMenu, + GoabxSideMenuGroup, + GoabxSideMenuHeading, + GoabxTable, + GoabxTabs, + GoabxTextArea, +} from "@abgov/react-components/experimental"; +import type { + GoabPaginationOnChangeDetail, + GoabTabsOnChangeDetail, +} from "@abgov/ui-components-common"; + +export function Feat3241Route() { + const [goabxDrawerOpen, setGoabxDrawerOpen] = useState(false); + const [goabDrawerOpen, setGoabDrawerOpen] = useState(false); + const [goabxModalOpen, setGoabxModalOpen] = useState(false); + const [goabModalOpen, setGoabModalOpen] = useState(false); + const [goabxPage, setGoabxPage] = useState(1); + const [goabPage, setGoabPage] = useState(1); + const [goabxTab, setGoabxTab] = useState(1); + const [goabTab, setGoabTab] = useState(1); + const [goabxFilterClicks, setGoabxFilterClicks] = useState(0); + const [goabFilterClicks, setGoabFilterClicks] = useState(0); + const [goabxNotificationStatus, setGoabxNotificationStatus] = useState( + "No dismiss action yet.", + ); + const [goabNotificationStatus, setGoabNotificationStatus] = useState( + "No dismiss action yet.", + ); + + const onGoabxPaginationChange = (detail: GoabPaginationOnChangeDetail) => { + setGoabxPage(detail.page); + }; + + const onGoabPaginationChange = (detail: GoabPaginationOnChangeDetail) => { + setGoabPage(detail.page); + }; + + const onGoabxTabsChange = (detail: GoabTabsOnChangeDetail) => { + setGoabxTab(detail.tab); + }; + + const onGoabTabsChange = (detail: GoabTabsOnChangeDetail) => { + setGoabTab(detail.tab); + }; + + const onGoabxFilterClick = () => { + setGoabxFilterClicks((prev) => prev + 1); + }; + + const onGoabFilterClick = () => { + setGoabFilterClicks((prev) => prev + 1); + }; + + const onGoabxNotificationDismiss = () => { + setGoabxNotificationStatus("Goabx notification dismissed."); + }; + + const onGoabNotificationDismiss = () => { + setGoabNotificationStatus("Goab notification dismissed."); + }; + + const noop = () => { + /* nothing */ + }; + + return ( + <> + Feat 3241 - Experimental wrapper checks + + Side-by-side comparisons of Goabx wrappers and the matching Goab components. + + + Also implements all new properties created for Goabx components + + + + Footer + + +
+ + GoabxFooter + + + + Arts and culture + + + Privacy + + +
+
+ + GoabFooter + + + + Arts and culture + + + Privacy + + +
+
+ + + Badge + + +
+ + GoabxBadge + + + + Size = large + + + + Emphasis = subtle + + + + New type property value + + +
+
+ + GoabBadge + + + + Type property value that no longer exists + + + Should only work with design tokens v1 + + +
+
+ + + Button + + +
+ + GoabxButton + + + Goabx action + +
+
+ + GoabButton + + + Goab action + +
+
+ + + Calendar + + +
+ + GoabxCalendar + + +
+
+ + GoabCalendar + + +
+
+ + + Callout + + +
+ + GoabxCallout + + + Experimental callout content. + + + Emphasis = high + + + Experimental callout content + +
+
+ + GoabCallout + + + Standard callout content. + +
+
+ + + Datepicker + + +
+ + GoabxDatepicker + + +
+
+ + GoabDatepicker + + +
+
+ + + Form Item and Input + + +
+ + GoabxFormItem + GoabxInput + + + + + + Form Item - Input type = text-input + + + + + + Input - Size = compact + + + + +
+
+ + GoabFormItem + GoabInput + + + + +
+
+ + + Checkbox + + +
+ + GoabxCheckbox + + + + Size = compact + + +
+
+ + GoabCheckbox + + +
+
+ + + Dropdown and Items + + +
+ + GoabxDropdown + + + + + + + + + + Size = compact + + + + + + + + +
+
+ + GoabDropdown + + + + + + + + +
+
+ + + Textarea + + +
+ + GoabxTextArea + + + + + + Text Area - Size = compact + + + + +
+
+ + GoabTextArea + + + + +
+
+ + + Radio Group and Items + + +
+ + GoabxRadioGroup + + + + + + + + + Radio Group - Size = compact + + + Radio Item - Compact = true + + + + + + + +
+
+ + GoabRadioGroup + + + + + + + +
+
+ + + Filter Chip + + +
+ + GoabxFilterChip w/ Secondary Text + + + + Leading Icon + + + Clicks: {goabxFilterClicks} +
+
+ + GoabFilterChip + + + Clicks: {goabFilterClicks} +
+
+ + + Link + + +
+ + GoabxLink + + + + Goabx link + + + + Color = dark + + + + Goabx link + + + + Size = large + + + + Goabx link + + +
+
+ + GoabLink + + + + Goab link + + +
+
+ + + Notification + + +
+ + GoabxNotification + + + Goabx notification content. + + + Emphasis = low + + + Goabx notification content. + + + Compact = true + + + Goabx notification content. + + Status: {goabxNotificationStatus} +
+
+ + GoabNotification + + + Goab notification content. + + Status: {goabNotificationStatus} +
+
+ + + Pagination + + +
+ + GoabxPagination + + + Current page: {goabxPage} +
+
+ + GoabPagination + + + Current page: {goabPage} +
+
+ + + Tabs + + +
+ + GoabxTabs + + + + Goabx tab one content. + + + Goabx tab two content. + + + Goabx tab three content. + + + + Selected tab: {goabxTab} + +
+
+ + GoabTabs + + + + Goab tab one content. + + + Goab tab two content. + + + Goab tab three content. + + + + Selected tab: {goabTab} + +
+
+ + + Table + + +
+ + GoabxTable + + + + + Service + Status + + + + + Payments + Active + + + Reporting + Pending + + + + + GoabxTable - Striped = true + + + + + Service + Status + + + + + Payments + Active + + + Reporting + Pending + + + +
+
+ + GoabTable + + + + + Service + Status + + + + + Payments + Active + + + Reporting + Pending + + + +
+
+ + + File Upload Input + + +
+ + GoabxFileUploadInput + + + noop} + /> + +
+
+ + GoabFileUploadInput + + + noop} + /> + +
+
+ + + File Upload Card + + +
+ + GoabxFileUploadCard + + +
+
+ + GoabFileUploadCard + + +
+
+ + + Drawer + + +
+ + GoabxDrawer + + setGoabxDrawerOpen(true)}> + Open Goabx drawer + + setGoabxDrawerOpen(false)}> + Close + + } + onClose={() => setGoabxDrawerOpen(false)} + > + Goabx drawer content. + +
+
+ + GoabDrawer + + setGoabDrawerOpen(true)}> + Open Goab drawer + + setGoabDrawerOpen(false)}> + Close + + } + onClose={() => setGoabDrawerOpen(false)} + > + Goab drawer content. + +
+
+ + + Modal + + +
+ + GoabxModal + + setGoabxModalOpen(true)}> + Open Goabx modal + + + setGoabxModalOpen(false)}> + Cancel + + setGoabxModalOpen(false)}> + Confirm + + + } + onClose={() => setGoabxModalOpen(false)} + > + Goabx modal content. + +
+
+ + GoabModal + + setGoabModalOpen(true)}> + Open Goab modal + + + setGoabModalOpen(false)}> + Cancel + + setGoabModalOpen(false)}> + Confirm + + + } + onClose={() => setGoabModalOpen(false)} + > + Goab modal content. + +
+
+ + + Side Menu + + +
+ + GoabxSideMenu + + + } + icon="settings" + > + Goabx navigation + + + Goabx home + Goabx settings + + +
+
+ + GoabSideMenu + + + } + icon="settings" + > + Goab navigation + + + Goab home + Goab settings + + +
+
+ + ); +} diff --git a/libs/angular-components/src/experimental/.eslintrc.json b/libs/angular-components/src/experimental/.eslintrc.json new file mode 100644 index 0000000000..11f5d77431 --- /dev/null +++ b/libs/angular-components/src/experimental/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "goabx", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "goabx", + "style": "kebab-case" + } + ] + } + } + ] +} diff --git a/libs/angular-components/src/experimental/badge/badge.spec.ts b/libs/angular-components/src/experimental/badge/badge.spec.ts new file mode 100644 index 0000000000..1ea71e751a --- /dev/null +++ b/libs/angular-components/src/experimental/badge/badge.spec.ts @@ -0,0 +1,111 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxBadge } from "./badge"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabBadgeType, Spacing } from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; + +@Component({ + standalone: true, + imports: [GoabxBadge], + template: ` + + `, +}) +class TestBadgeComponent { + type?: GoabBadgeType; + content?: string; + testId?: string; + icon?: boolean; + ariaLabel?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; +} + +@Component({ + standalone: true, + imports: [GoabxBadge], + template: ` `, +}) +class TestBadgeNoIconComponent { + type?: GoabBadgeType; + content?: string; +} + +describe("GoABBadge", () => { + let fixture: ComponentFixture; + let component: TestBadgeComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxBadge, TestBadgeComponent, TestBadgeNoIconComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + })); + + it("should render and set the props correctly", fakeAsync(() => { + fixture = TestBed.createComponent(TestBadgeComponent); + component = fixture.componentInstance; + component.type = "information"; + component.content = "Information"; + component.icon = true; + component.ariaLabel = "123"; + component.testId = "test-id"; + component.mt = "xs" as Spacing; + component.mb = "m" as Spacing; + component.ml = "l" as Spacing; + component.mr = "xl" as Spacing; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const badgeElement = fixture.debugElement.query(By.css("goa-badge")).nativeElement; + expect(badgeElement.getAttribute("type")).toBe("information"); + expect(badgeElement.getAttribute("content")).toBe("Information"); + expect(badgeElement.getAttribute("icon")).toBe("true"); + expect(badgeElement.getAttribute("arialabel")).toBe("123"); + expect(badgeElement.getAttribute("testid")).toBe("test-id"); + expect(badgeElement.getAttribute("mt")).toBe(component.mt); + expect(badgeElement.getAttribute("mb")).toBe(component.mb); + expect(badgeElement.getAttribute("ml")).toBe(component.ml); + expect(badgeElement.getAttribute("mr")).toBe(component.mr); + })); + + it("should not set icon attribute by default (icon undefined)", fakeAsync(() => { + const noIconFixture = TestBed.createComponent(TestBadgeNoIconComponent); + const noIconComponent = noIconFixture.componentInstance; + noIconComponent.type = "information"; + noIconComponent.content = "Information"; + noIconFixture.detectChanges(); + tick(); + noIconFixture.detectChanges(); + const badgeElement = noIconFixture.debugElement.query( + By.css("goa-badge"), + ).nativeElement; + expect(badgeElement.getAttribute("icon")).toBe("false"); + })); + + it("should not render icon when icon is false", fakeAsync(() => { + fixture = TestBed.createComponent(TestBadgeComponent); + component = fixture.componentInstance; + component.type = "information"; + component.content = "Information"; + component.icon = false; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const badgeElement = fixture.debugElement.query(By.css("goa-badge")).nativeElement; + expect(badgeElement.getAttribute("icon")).toBe("false"); + })); +}); diff --git a/libs/angular-components/src/experimental/badge/badge.ts b/libs/angular-components/src/experimental/badge/badge.ts new file mode 100644 index 0000000000..ea69ec5dab --- /dev/null +++ b/libs/angular-components/src/experimental/badge/badge.ts @@ -0,0 +1,75 @@ +import { + GoabxBadgeType, + GoabIconType, + GoabBadgeSize, + GoabBadgeEmphasis, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + booleanAttribute, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-badge", + template: ` + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], + styles: [ + ` + :host { + display: contents; + } + `, + ], +}) +export class GoabxBadge extends GoabBaseComponent implements OnInit { + @Input() type?: GoabxBadgeType; + @Input() content?: string; + // Ensure boolean input; attribute only set when true so default behaviour is false + @Input({ transform: booleanAttribute }) icon?: boolean; + @Input() iconType?: GoabIconType; + @Input() size?: GoabBadgeSize = "medium"; + @Input() emphasis?: GoabBadgeEmphasis = "strong"; + @Input() ariaLabel?: string; + + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } +} diff --git a/libs/angular-components/src/experimental/base.component.ts b/libs/angular-components/src/experimental/base.component.ts new file mode 100644 index 0000000000..74768456f3 --- /dev/null +++ b/libs/angular-components/src/experimental/base.component.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Spacing } from "@abgov/ui-components-common"; +import { + booleanAttribute, + Component, + Input, + ElementRef, + ViewChild, + Renderer2, +} from "@angular/core"; +import { ControlValueAccessor } from "@angular/forms"; + +@Component({ + standalone: true, + template: ``, //** IMPLEMENT IN SUBCLASS +}) +export abstract class GoabBaseComponent { + @Input() mt?: Spacing; + @Input() mb?: Spacing; + @Input() ml?: Spacing; + @Input() mr?: Spacing; + @Input() testId?: string; +} + +@Component({ + standalone: true, + template: ``, //** IMPLEMENT IN SUBCLASS +}) +/** + * An abstract base class that extends `GoabBaseComponent` and implements the `ControlValueAccessor` interface. + * This class provides a foundation for creating custom form controls in Angular, enabling them to integrate + * seamlessly with Angular forms. It includes support for handling value changes, touch events, and disabled states. + * + * ## Features + * - Supports `disabled="true"` and `error="true` attribute bindings for convenience. + * - Handles form control value changes and touch events via `ControlValueAccessor` methods. + * - Allows for flexible value types (`unknown`), making it suitable for various data types like integers, dates, or booleans. + * - Uses ViewChild to capture a reference to the native GOA web component element via `#goaComponentRef`. + * - Uses Renderer2 for safe DOM manipulation (compatible with SSR and security best practices). + * + * ## Usage + * Extend this class to create custom form controls. Child components must: + * 1. Add `#goaComponentRef` template reference to their `goa-*` element in the template + * 2. Inject `Renderer2` in their constructor and pass it to `super(renderer)` + * + * ### Example: + * ```typescript + * @Component({ + * template: `` + * }) + * export class GoabInput extends GoabControlValueAccessor { + * constructor(private cdr: ChangeDetectorRef, renderer: Renderer2) { + * super(renderer); // Required: pass Renderer2 to base class + * } + * } + * ``` + * + * ## Properties + * - `id?`: An optional identifier for the component. + * - `disabled?`: A boolean indicating whether the component is disabled. + * - `error?`: A boolean indicating whether the component is in an error state. + * - `value?`: The current value of the component, which can be of any type. + * + * ## Methods + * - `markAsTouched()`: Marks the component as touched and triggers the `fcTouched` callback if defined. + * - `writeValue(value: unknown)`: Writes a new value to the form control (can be overridden for special behavior like checkbox). + * - `registerOnChange(fn: any)`: Registers a function to handle changes in the form control value. + * - `registerOnTouched(fn: any)`: Registers a function to handle touch events on the form control. + * - `setDisabledState?(isDisabled: boolean)`: Sets the disabled state of the component. + * - `convertValueToString(value: unknown)`: Converts a value to a string for DOM attribute assignment (can be overridden). + * + * ## Callbacks + * - `fcChange?`: A function to handle changes in the form control value. + * - `fcTouched?`: A function to handle touch events on the form control. + */ +export abstract class GoabControlValueAccessor + extends GoabBaseComponent + implements ControlValueAccessor +{ + @Input() id?: string; + // supports disabled="true" instead of [disabled]="true" + @Input({ transform: booleanAttribute }) public disabled?: boolean; + // supports error="true" instead of [error]="true" + @Input({ transform: booleanAttribute }) public error?: boolean; + // this should be unknown (not string) as it might be an integer or a date or a boolean + @Input() value?: unknown | null | undefined; + + // implement ControlValueAccessor + + /** + * Function to handle changes in the form control value. + * @param {unknown} value - The new value. + */ + public fcChange?: (value: unknown) => void; + + /** + * Function to handle touch events on the form control. + */ + public fcTouched?: () => unknown; + + private touched = false; + + /** + * Marks the component as touched. If the component is not already marked as touched, + * it triggers the `fcTouched` callback (if defined) and sets the `touched` property to `true`. + */ + public markAsTouched() { + if (!this.touched) { + this.fcTouched?.(); + this.touched = true; + } + } + + /** + * Reference to the native GOA web component element. + * Child templates should declare `#goaComponentRef` on the `goa-*` element. + * The base class captures it here so children don't need their own ViewChild. + */ + @ViewChild("goaComponentRef", { static: false, read: ElementRef }) + protected goaComponentRef?: ElementRef; + + constructor(protected renderer: Renderer2) { + super(); + } + + /** + * Convert an arbitrary value into a string for DOM attribute assignment. + * Child classes can override when they need special formatting. + * @param value The value to convert + * @returns string representation or empty string for nullish/empty + */ + protected convertValueToString(value: unknown): string { + if (value === null || value === undefined || value === "") { + return ""; + } + return String(value); + } + + /** + * Writes a new value to the form control. + * @param {unknown} value - The value to write. + */ + public writeValue(value: unknown): void { + this.value = value; + const el = this.goaComponentRef?.nativeElement as HTMLElement | undefined; + if (el) { + const stringValue = this.convertValueToString(value); + this.renderer.setAttribute(el, "value", stringValue); + } + } + + /** + * Registers a function to call when the form control value changes. + * @param {function} fn - The function to call. + */ + public registerOnChange(fn: any): void { + this.fcChange = fn; + } + + /** + * Registers a function to call when the form control is touched. + * @param {function} fn - The function to call. + */ + public registerOnTouched(fn: any): void { + this.fcTouched = fn; + } + + /** + * Sets the disabled state of the component. + * + * @param isDisabled - A boolean indicating whether the component should be disabled. + */ + public setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } +} diff --git a/libs/angular-components/src/experimental/button/button.spec.ts b/libs/angular-components/src/experimental/button/button.spec.ts new file mode 100644 index 0000000000..c8cecded41 --- /dev/null +++ b/libs/angular-components/src/experimental/button/button.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxButton } from "./button"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabButtonSize, GoabButtonVariant, GoabIconType, Spacing, GoabButtonType } from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; +import { fireEvent } from "@testing-library/dom"; + +@Component({ + standalone: true, + imports: [GoabxButton], + template: ` + + {{buttonText}} + + ` +}) +class TestButtonComponent{ + type?: GoabButtonType; + size?: GoabButtonSize; + variant?: GoabButtonVariant; + disabled?: boolean; + leadingIcon?: GoabIconType; + trailingIcon?: GoabIconType; + testId?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + buttonText?: string; + + onClick() { + /* do nothing */ + } + +} + +describe("GoABButton", () => { + let fixture: ComponentFixture; + let component: TestButtonComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxButton, TestButtonComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestButtonComponent); + component = fixture.componentInstance; + component.buttonText = "Click me"; + component.type = "primary"; + component.size = "compact"; + component.variant = "destructive"; + component.leadingIcon = "car"; + component.trailingIcon = "bag"; + component.mt = "s"; + component.mr = "m"; + component.mb = "l"; + component.ml = "xl"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render the properties", () => { + const buttonElement = fixture.debugElement.query(By.css("goa-button")).nativeElement; + expect(buttonElement.getAttribute("type")).toBe("primary"); + expect(buttonElement.getAttribute("size")).toBe("compact"); + expect(buttonElement.getAttribute("variant")).toBe("destructive"); + expect(buttonElement.getAttribute("leadingicon")).toBe("car"); + expect(buttonElement.getAttribute("trailingicon")).toBe("bag"); + expect(buttonElement.getAttribute("mt")).toBe("s"); + expect(buttonElement.getAttribute("mr")).toBe("m"); + expect(buttonElement.getAttribute("mb")).toBe("l"); + expect(buttonElement.getAttribute("ml")).toBe("xl"); + // it should render the content + expect(buttonElement.textContent).toContain("Click me"); + }); + + it("should respond to click event", fakeAsync(() => { + const onClick = jest.spyOn(component, "onClick"); + const buttonElement = fixture.debugElement.query(By.css("goa-button")).nativeElement; + + fireEvent(buttonElement, new CustomEvent("_click")); + expect(onClick).toHaveBeenCalled(); + })) +}) diff --git a/libs/angular-components/src/experimental/button/button.ts b/libs/angular-components/src/experimental/button/button.ts new file mode 100644 index 0000000000..02bfaf735c --- /dev/null +++ b/libs/angular-components/src/experimental/button/button.ts @@ -0,0 +1,85 @@ +import { + GoabButtonSize, + GoabButtonType, + GoabButtonVariant, + GoabIconType, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + booleanAttribute, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-button", + imports: [CommonModule], + template: ` + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxButton extends GoabBaseComponent implements OnInit { + @Input() type?: GoabButtonType = "primary"; + @Input() size?: GoabButtonSize; + @Input() variant?: GoabButtonVariant; + @Input({ transform: booleanAttribute }) disabled?: boolean; + @Input() leadingIcon?: GoabIconType; + @Input() trailingIcon?: GoabIconType; + @Input() width?: string; + @Input() action?: string; + @Input() actionArg?: string; + @Input() actionArgs?: Record; + + @Output() onClick = new EventEmitter(); + + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + _onClick() { + this.onClick.emit(); + } + + protected readonly JSON = JSON; +} diff --git a/libs/angular-components/src/experimental/calendar/calendar.spec.ts b/libs/angular-components/src/experimental/calendar/calendar.spec.ts new file mode 100644 index 0000000000..5666f2881e --- /dev/null +++ b/libs/angular-components/src/experimental/calendar/calendar.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxCalendar } from "./calendar"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { fireEvent } from "@testing-library/dom"; +import { GoabCalendarOnChangeDetail, Spacing } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + imports: [GoabxCalendar], + template: ` + + + `, +}) +class TestCalendarComponent { + name?: string; + value?: Date; + min?: Date; + max?: Date; + testId?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + + onChange(event: GoabCalendarOnChangeDetail) { + /* do nothing */ + } +} + +describe("GoABCalendar", () => { + let fixture: ComponentFixture; + let component: TestCalendarComponent; + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxCalendar, TestCalendarComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestCalendarComponent); + component = fixture.componentInstance; + + component.name = "calendar"; + component.value = new Date(); + component.min = new Date(); + component.max = new Date(); + component.testId = "test-calendar"; + component.mt = "m"; + component.mb = "xl"; + component.ml = "s"; + component.mr = "l"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render properties", () => { + const calendar = fixture.nativeElement.querySelector("goa-calendar"); + expect(calendar.getAttribute("name")).toBe(component.name); + expect(calendar.getAttribute("min")).toBe(component.min?.toString()); + expect(calendar.getAttribute("max")).toBe(component.max?.toString()); + expect(calendar.getAttribute("testid")).toBe(component.testId); + expect(calendar.getAttribute("mt")).toBe(component.mt); + expect(calendar.getAttribute("mb")).toBe(component.mb); + expect(calendar.getAttribute("ml")).toBe(component.ml); + expect(calendar.getAttribute("mr")).toBe(component.mr); + }); + + it("should handle the event", () => { + const onChange = jest.spyOn(component, "onChange"); + const calendar = fixture.nativeElement.querySelector("goa-calendar"); + + fireEvent( + calendar, + new CustomEvent("_change", { + detail: { + type: "date", + value: new Date(), + name: component.name, + }, + }), + ); + expect(onChange).toHaveBeenCalled(); + }); +}); diff --git a/libs/angular-components/src/experimental/calendar/calendar.ts b/libs/angular-components/src/experimental/calendar/calendar.ts new file mode 100644 index 0000000000..418fdf6e68 --- /dev/null +++ b/libs/angular-components/src/experimental/calendar/calendar.ts @@ -0,0 +1,67 @@ +import { GoabCalendarOnChangeDetail } from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-calendar", + imports: [CommonModule], + template: ` + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxCalendar extends GoabBaseComponent implements OnInit { + version = 2; + + @Input() name?: string; + @Input() value?: Date; + @Input() min?: Date; + @Input() max?: Date; + + @Output() onChange = new EventEmitter(); + + isReady = false; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + _onChange(e: Event) { + const details = (e as CustomEvent).detail; + this.onChange.emit(details); + } +} diff --git a/libs/angular-components/src/experimental/callout/callout.spec.ts b/libs/angular-components/src/experimental/callout/callout.spec.ts new file mode 100644 index 0000000000..1ad7bb3201 --- /dev/null +++ b/libs/angular-components/src/experimental/callout/callout.spec.ts @@ -0,0 +1,80 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxCallout } from "./callout"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabCalloutSize, GoabCalloutType, Spacing } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + imports: [GoabxCallout], + template: ` + + Information to the user goes in the content. Information can include markup as + desired. + + `, +}) +class TestCalloutComponent { + type?: GoabCalloutType; + heading?: string; + size?: GoabCalloutSize; + testId?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; +} + +describe("GoABCallout", () => { + let fixture: ComponentFixture; + let component: TestCalloutComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxCallout, TestCalloutComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestCalloutComponent); + component = fixture.componentInstance; + + component.type = "information"; + component.heading = "Callout Title"; + component.size = "medium"; + component.testId = "test-callout"; + component.mt = "s"; + component.mr = "m"; + component.mb = "l"; + component.ml = "xl"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render properties", () => { + const el = fixture.nativeElement.querySelector("goa-callout"); + expect(el.getAttribute("heading")).toContain(component.heading); + expect(el.getAttribute("type")).toContain(component.type); + expect(el.getAttribute("size")).toContain(component.size); + expect(el.getAttribute("testid")).toContain(component.testId); + expect(el.getAttribute("maxwidth")).toContain("480px"); + expect(el.getAttribute("mt")).toBe(component.mt); + expect(el.getAttribute("mr")).toBe(component.mr); + expect(el.getAttribute("mb")).toBe(component.mb); + expect(el.getAttribute("ml")).toBe(component.ml); + + // render children + expect(el.textContent).toContain( + "Information to the user goes in the content. Information can include markup as desired.", + ); + }); +}); diff --git a/libs/angular-components/src/experimental/callout/callout.ts b/libs/angular-components/src/experimental/callout/callout.ts new file mode 100644 index 0000000000..ea2855d53f --- /dev/null +++ b/libs/angular-components/src/experimental/callout/callout.ts @@ -0,0 +1,68 @@ +import { + GoabCalloutAriaLive, + GoabCalloutSize, + GoabCalloutType, + GoabCalloutIconTheme, + GoabCalloutEmphasis, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-callout", + imports: [CommonModule], + template: ` + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxCallout extends GoabBaseComponent implements OnInit { + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + @Input() type?: GoabCalloutType = "information"; + @Input() heading?: string = ""; + @Input() size?: GoabCalloutSize = "large"; + @Input() maxWidth?: string; + @Input() ariaLive?: GoabCalloutAriaLive = "off"; + @Input() iconTheme?: GoabCalloutIconTheme = "outline"; + @Input() emphasis?: GoabCalloutEmphasis = "medium"; +} diff --git a/libs/angular-components/src/experimental/checkbox/checkbox.spec.ts b/libs/angular-components/src/experimental/checkbox/checkbox.spec.ts new file mode 100644 index 0000000000..2949ac63d5 --- /dev/null +++ b/libs/angular-components/src/experimental/checkbox/checkbox.spec.ts @@ -0,0 +1,263 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxCheckbox } from "./checkbox"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { fireEvent } from "@testing-library/dom"; +import { By } from "@angular/platform-browser"; +import { Spacing } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + imports: [GoabxCheckbox], + template: ` + + + `, +}) +class TestCheckboxComponent { + name?: string; + checked?: boolean; + text?: string; + value?: string | number | boolean; + disabled?: boolean; + error?: boolean; + ariaLabel?: string; + testId?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + + onChange() { + /* do nothing */ + } +} + +describe("GoabxCheckbox", () => { + let fixture: ComponentFixture; + let component: TestCheckboxComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestCheckboxComponent, GoabxCheckbox, ReactiveFormsModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestCheckboxComponent); + component = fixture.componentInstance; + + component.name = "foo"; + component.value = "bar"; + component.text = "to display"; + component.disabled = false; + component.checked = true; + component.error = false; + component.testId = "testId"; + component.mt = "s"; + component.mr = "m"; + component.mb = "l"; + component.ml = "xl"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render properties", () => { + const checkboxElement = fixture.debugElement.query( + By.css("goa-checkbox"), + ).nativeElement; + expect(checkboxElement.getAttribute("name")).toBe(component.name); + expect(checkboxElement.getAttribute("text")).toBe(component.text); + expect(checkboxElement.getAttribute("testid")).toBe(component.testId); + expect(checkboxElement.getAttribute("mt")).toBe(component.mt); + expect(checkboxElement.getAttribute("mr")).toBe(component.mr); + expect(checkboxElement.getAttribute("mb")).toBe(component.mb); + expect(checkboxElement.getAttribute("ml")).toBe(component.ml); + expect(checkboxElement.getAttribute("description")).toBe("Description text"); + expect(checkboxElement.getAttribute("maxwidth")).toBe("480px"); + }); + + it("should handle onChange event", async () => { + const onChange = jest.spyOn(component, "onChange"); + + const checkboxElement = fixture.debugElement.query( + By.css("goa-checkbox"), + ).nativeElement; + + fireEvent( + checkboxElement, + new CustomEvent("_change", { + detail: { name: "foo", value: "bar", checked: true }, + }), + ); + + expect(onChange).toHaveBeenCalled(); + }); + + describe("writeValue", () => { + it("should set checked attribute to true when value is truthy", () => { + const checkboxComponent = fixture.debugElement.query( + By.css("goabx-checkbox"), + ).componentInstance; + const checkboxElement = fixture.debugElement.query( + By.css("goa-checkbox"), + ).nativeElement; + + checkboxComponent.writeValue(true); + expect(checkboxElement.getAttribute("checked")).toBe("true"); + + checkboxComponent.writeValue("some value"); + expect(checkboxElement.getAttribute("checked")).toBe("true"); + + checkboxComponent.writeValue(1); + expect(checkboxElement.getAttribute("checked")).toBe("true"); + }); + + it("should set checked attribute to false when value is falsy", () => { + const checkboxComponent = fixture.debugElement.query( + By.css("goabx-checkbox"), + ).componentInstance; + const checkboxElement = fixture.debugElement.query( + By.css("goa-checkbox"), + ).nativeElement; + + checkboxComponent.writeValue(false); + expect(checkboxElement.getAttribute("checked")).toBe("false"); + + checkboxComponent.writeValue(null); + expect(checkboxElement.getAttribute("checked")).toBe("false"); + + checkboxComponent.writeValue(undefined); + expect(checkboxElement.getAttribute("checked")).toBe("false"); + + checkboxComponent.writeValue(""); + expect(checkboxElement.getAttribute("checked")).toBe("false"); + }); + + it("should update component value property", () => { + const checkboxComponent = fixture.debugElement.query( + By.css("goabx-checkbox"), + ).componentInstance; + + checkboxComponent.writeValue(true); + expect(checkboxComponent.value).toBe(true); + + checkboxComponent.writeValue(null); + expect(checkboxComponent.value).toBe(null); + }); + }); +}); + +@Component({ + standalone: true, + imports: [GoabxCheckbox], + template: ` + + + A description slot + + + `, +}) +class TestCheckboxWithDescriptionSlotComponent { + /** do nothing **/ +} + +describe("Checkbox with description slot", () => { + let fixture: ComponentFixture; + + it("should render with slot description", fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TestCheckboxWithDescriptionSlotComponent, + GoabxCheckbox, + ReactiveFormsModule, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestCheckboxWithDescriptionSlotComponent); + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const checkboxElement = fixture.debugElement.query( + By.css("goa-checkbox"), + ).nativeElement; + const slotDescription = checkboxElement.querySelector("[slot='description']"); + expect(slotDescription.textContent).toContain("A description slot"); + })); +}); + +@Component({ + standalone: true, + imports: [GoabxCheckbox], + template: ` + + + A reveal slot + + + `, +}) +class TestCheckboxWithRevealSlotComponent {} + +describe("Checkbox with reveal slot", () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestCheckboxWithRevealSlotComponent, GoabxCheckbox, ReactiveFormsModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestCheckboxWithRevealSlotComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render with slot reveal", () => { + const checkboxElement = fixture.debugElement.query( + By.css("goa-checkbox"), + ).nativeElement; + const slotReveal = checkboxElement.querySelector("[slot='reveal']"); + expect(slotReveal.textContent).toContain("A reveal slot"); + }); + + it("should pass the revealAriaLabel property to the web component", () => { + const checkboxElement = fixture.debugElement.query( + By.css("goa-checkbox"), + ).nativeElement; + expect(checkboxElement.getAttribute("revealarialabel")).toBe( + "Screen reader announcement for reveal content", + ); + }); +}); diff --git a/libs/angular-components/src/experimental/checkbox/checkbox.ts b/libs/angular-components/src/experimental/checkbox/checkbox.ts new file mode 100644 index 0000000000..72dbe2c89f --- /dev/null +++ b/libs/angular-components/src/experimental/checkbox/checkbox.ts @@ -0,0 +1,130 @@ +import { + GoabCheckboxOnChangeDetail, + GoabCheckboxSize, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + forwardRef, + TemplateRef, + booleanAttribute, + OnInit, + ChangeDetectorRef, + Renderer2, +} from "@angular/core"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; +import { NgTemplateOutlet, CommonModule } from "@angular/common"; +import { GoabControlValueAccessor } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-checkbox", + template: ` + +
+ +
+
+ +
+
`, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => GoabxCheckbox), + }, + ], + imports: [NgTemplateOutlet, CommonModule], +}) +export class GoabxCheckbox extends GoabControlValueAccessor implements OnInit { + isReady = false; + version = "2"; + + constructor( + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + @Input() name?: string; + @Input({ transform: booleanAttribute }) checked?: boolean; + @Input({ transform: booleanAttribute }) indeterminate?: boolean; + @Input() text?: string; + // ** NOTE: can we just use the base component for this? + @Input() override value?: string | number | boolean | null; + @Input() ariaLabel?: string; + @Input() description!: string | TemplateRef; + @Input() reveal?: TemplateRef; + @Input() revealArialLabel?: string; + @Input() maxWidth?: string; + @Input() size?: GoabCheckboxSize = "default"; + + @Output() onChange = new EventEmitter(); + + getDescriptionAsString(): string { + return typeof this.description === "string" ? this.description : ""; + } + + getDescriptionAsTemplate(): TemplateRef | null { + if (this.description) { + return typeof this.description === "string" ? null : this.description; + } + return null; + } + + _onChange(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + this.onChange.emit(detail); + this.markAsTouched(); + this.fcChange?.(detail.binding === "check" ? detail.checked : detail.value || ""); + } + + // Checkbox is a special case: it uses `checked` instead of `value`. + override writeValue(value: string | number | boolean | null): void { + this.value = value; + this.checked = !!value; + + const el = this.goaComponentRef?.nativeElement as HTMLElement | undefined; + if (el) { + this.renderer.setAttribute(el, "checked", this.checked ? "true" : "false"); + } + } +} diff --git a/libs/angular-components/src/experimental/date-picker/date-picker.spec.ts b/libs/angular-components/src/experimental/date-picker/date-picker.spec.ts new file mode 100644 index 0000000000..d326767f9f --- /dev/null +++ b/libs/angular-components/src/experimental/date-picker/date-picker.spec.ts @@ -0,0 +1,102 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxDatePicker } from "./date-picker"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { Spacing } from "@abgov/ui-components-common"; +import { ReactiveFormsModule } from "@angular/forms"; +import { addMonths } from "date-fns"; +import { By } from "@angular/platform-browser"; +import { fireEvent } from "@testing-library/dom"; + +@Component({ + standalone: true, + imports: [GoabxDatePicker], + template: ` + + `, +}) +class TestDatePickerComponent { + name?: string; + value?: Date | string; + min?: Date | string; + max?: Date | string; + error?: boolean; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + + onChange() { + /* do nothing */ + } +} + +describe("GoABDatePicker", () => { + let fixture: ComponentFixture; + let component: TestDatePickerComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxDatePicker, ReactiveFormsModule, TestDatePickerComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestDatePickerComponent); + component = fixture.componentInstance; + // Assign values + const value = new Date(); + component.name = "foo"; + component.min = addMonths(value, -1); + component.max = addMonths(value, 1); + component.value = value; + component.error = true; + component.mt = "l"; + component.mb = "m"; + component.ml = "s"; + component.mr = "xs"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render successfully", () => { + const el = fixture.debugElement.query(By.css("goa-date-picker")).nativeElement; + expect(el).toBeTruthy(); + + expect(el?.getAttribute("name")).toBe(component.name); + expect(el?.getAttribute("value")).toBe((component.value as Date)?.toISOString()); + expect(el?.getAttribute("error")).toBe(`${component.error}`); + expect(el?.getAttribute("min")).toBe(component.min?.toString()); + expect(el?.getAttribute("max")).toBe(component.max?.toString()); + expect(el?.getAttribute("mt")).toBe(component.mt); + expect(el?.getAttribute("mb")).toBe(component.mb); + expect(el?.getAttribute("ml")).toBe(component.ml); + expect(el?.getAttribute("mr")).toBe(component.mr); + expect(el?.getAttribute("type")).toBe("input"); + }); + + it("should handle event", fakeAsync(() => { + const onChange = jest.spyOn(component, "onChange"); + const el = fixture.debugElement.query(By.css("goa-date-picker")).nativeElement; + + fireEvent( + el, + new CustomEvent("_change", { + detail: { name: component.name, value: new Date() }, + }), + ); + + expect(onChange).toHaveBeenCalled(); + })); +}); diff --git a/libs/angular-components/src/experimental/date-picker/date-picker.ts b/libs/angular-components/src/experimental/date-picker/date-picker.ts new file mode 100644 index 0000000000..edd92c0056 --- /dev/null +++ b/libs/angular-components/src/experimental/date-picker/date-picker.ts @@ -0,0 +1,139 @@ +import { + GoabDatePickerInputType, + GoabDatePickerOnChangeDetail, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + forwardRef, + ElementRef, + HostListener, + OnInit, + ChangeDetectorRef, + Renderer2, +} from "@angular/core"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CommonModule } from "@angular/common"; +import { GoabControlValueAccessor } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-date-picker", + imports: [CommonModule], + template: ` + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => GoabxDatePicker), + }, + ], +}) +export class GoabxDatePicker extends GoabControlValueAccessor implements OnInit { + isReady = false; + version = 2; + + @Input() name?: string; + @Input() override value?: Date | string | null | undefined; + @Input() min?: Date | string; + @Input() max?: Date | string; + @Input() type?: GoabDatePickerInputType; + /*** + * @deprecated This property has no effect and will be removed in a future version + */ + @Input() relative?: boolean; + @Input() width?: string; + + @Output() onChange = new EventEmitter(); + + formatValue(val: Date | string | null | undefined): string { + if (!val) return ""; + + if (val instanceof Date) { + return val.toISOString(); + } + + return val; + } + + _onChange(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + this.onChange.emit(detail); + this.markAsTouched(); + this.fcChange?.(detail.value); + } + + constructor( + protected elementRef: ElementRef, + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + + if (this.value && typeof this.value !== "string") { + console.warn( + "Using a `Date` type for value is deprecated. Instead use a string of the format `yyyy-mm-dd`", + ); + } + } + + override setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + this.elementRef.nativeElement.disabled = isDisabled; + } + + @HostListener("disabledChange", ["$event.detail.disabled"]) + listenDisabledChange(isDisabled: boolean) { + this.setDisabledState(isDisabled); + } + + override writeValue(value: Date | null): void { + this.value = value; + + const datePickerEl = this.goaComponentRef?.nativeElement as HTMLElement | undefined; + if (datePickerEl) { + if (!value) { + this.renderer.setAttribute(datePickerEl, "value", ""); + } else { + this.renderer.setAttribute( + datePickerEl, + "value", + value instanceof Date ? value.toISOString() : value, + ); + } + } + } +} diff --git a/libs/angular-components/src/experimental/drawer/drawer.spec.ts b/libs/angular-components/src/experimental/drawer/drawer.spec.ts new file mode 100644 index 0000000000..5af42fd2b0 --- /dev/null +++ b/libs/angular-components/src/experimental/drawer/drawer.spec.ts @@ -0,0 +1,71 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxDrawer } from "./drawer"; +import { Component } from "@angular/core"; +import { GoabDrawerPosition, GoabDrawerSize } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + imports: [GoabxDrawer], + template: ` + + {{ content }} + +

Heading

+
+ + + +
+ `, +}) +class TestDrawerComponent { + open = false; + position: GoabDrawerPosition = "bottom"; + maxSize = "50ch" as GoabDrawerSize; + testId = "test-drawer"; + content = "Test Content"; + + // Empty method for testing close event emission + onClose() { + /* empty */ + } +} + +describe("GoabxDrawer", () => { + let component: TestDrawerComponent; + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestDrawerComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestDrawerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("renders with string heading", fakeAsync(() => { + const drawerElement = fixture.nativeElement.querySelector("goa-drawer"); + expect(drawerElement).toBeTruthy(); + + expect(drawerElement.getAttribute("position")).toBe("bottom"); + const headingContent = drawerElement.querySelector("[slot='heading']"); + expect(headingContent?.textContent).toContain("Heading"); + expect(drawerElement.getAttribute("maxsize")).toBe("50ch"); + expect(drawerElement.getAttribute("testid")).toBe("test-drawer"); + expect(drawerElement.textContent).toContain("Test Content"); + const actionsContent = drawerElement.querySelector("[slot='actions']"); + expect(actionsContent?.textContent).toContain("Close"); + })); +}); diff --git a/libs/angular-components/src/experimental/drawer/drawer.ts b/libs/angular-components/src/experimental/drawer/drawer.ts new file mode 100644 index 0000000000..b64341aa49 --- /dev/null +++ b/libs/angular-components/src/experimental/drawer/drawer.ts @@ -0,0 +1,77 @@ +import { NgTemplateOutlet, CommonModule } from "@angular/common"; +import { + booleanAttribute, + Component, + CUSTOM_ELEMENTS_SCHEMA, + EventEmitter, + Input, + Output, + TemplateRef, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { GoabDrawerPosition, GoabDrawerSize } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + selector: "goabx-drawer", + imports: [NgTemplateOutlet, CommonModule], + template: ` + + +
+ +
+
+ +
+
+ `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxDrawer implements OnInit { + version = "2"; + + @Input({ required: true, transform: booleanAttribute }) open!: boolean; + @Input({ required: true }) position!: GoabDrawerPosition; + @Input() heading!: string | TemplateRef; + @Input() maxSize?: GoabDrawerSize; + @Input() testId?: string; + @Input() actions!: TemplateRef; + @Output() onClose = new EventEmitter(); + + isReady = false; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + _onClose() { + this.onClose.emit(); + } + + getHeadingAsString(): string { + return this.heading instanceof TemplateRef ? "" : this.heading; + } + + getHeadingAsTemplate(): TemplateRef | null { + if (!this.heading) return null; + return this.heading instanceof TemplateRef ? this.heading : null; + } +} diff --git a/libs/angular-components/src/experimental/dropdown-item/dropdown-item.spec.ts b/libs/angular-components/src/experimental/dropdown-item/dropdown-item.spec.ts new file mode 100644 index 0000000000..15b4371adf --- /dev/null +++ b/libs/angular-components/src/experimental/dropdown-item/dropdown-item.spec.ts @@ -0,0 +1,17 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { GoabxDropdownItem } from "./dropdown-item"; + +let component: GoabxDropdownItem; +let fixture: ComponentFixture; + +beforeEach(() => { + TestBed.configureTestingModule({ + imports: [GoabxDropdownItem], + }); + fixture = TestBed.createComponent(GoabxDropdownItem); + component = fixture.componentInstance; +}); + +it("should render", () => { + expect(component).toBeTruthy(); +}); diff --git a/libs/angular-components/src/experimental/dropdown-item/dropdown-item.ts b/libs/angular-components/src/experimental/dropdown-item/dropdown-item.ts new file mode 100644 index 0000000000..98a6d58f32 --- /dev/null +++ b/libs/angular-components/src/experimental/dropdown-item/dropdown-item.ts @@ -0,0 +1,47 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabDropdownItemMountType } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + selector: "goabx-dropdown-item", + template: ` + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class GoabxDropdownItem implements OnInit { + @Input() value?: string; + @Input() filter?: string; + @Input() label?: string; + @Input() name?: string; + @Input() mountType?: GoabDropdownItemMountType; + + isReady = false; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } +} diff --git a/libs/angular-components/src/experimental/dropdown/dropdown.spec.ts b/libs/angular-components/src/experimental/dropdown/dropdown.spec.ts new file mode 100644 index 0000000000..2b67220097 --- /dev/null +++ b/libs/angular-components/src/experimental/dropdown/dropdown.spec.ts @@ -0,0 +1,256 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxDropdown } from "./dropdown"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabIconType, Spacing } from "@abgov/ui-components-common"; +import { GoabxDropdownItem } from "../dropdown-item/dropdown-item"; +import { ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { fireEvent } from "@testing-library/dom"; + +@Component({ + standalone: true, + imports: [GoabxDropdown, GoabxDropdownItem], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + template: ` + + + + + + `, +}) +class TestDropdownComponent { + name?: string; + value?: string; + ariaLabel?: string; + ariaLabelledBy?: string; + id?: string; + disabled?: boolean; + error?: boolean; + filterable?: boolean; + leadingIcon?: GoabIconType; + maxHeight?: string; + multiselect?: boolean; + native?: boolean; + placeholder?: string; + testId?: string; + width?: string; + maxWidth?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + autoComplete?: string; + + onChange() { + /** do nothing **/ + } +} + +describe("GoABDropdown", () => { + let fixture: ComponentFixture; + let component: TestDropdownComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TestDropdownComponent, + GoabxDropdown, + GoabxDropdownItem, + ReactiveFormsModule, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestDropdownComponent); + component = fixture.componentInstance; + + // Assign values + component.leadingIcon = "color-wand"; + component.name = "favColor"; + component.value = "blue"; + component.maxHeight = "100px"; + component.placeholder = "Select..."; + component.filterable = true; + component.disabled = true; + component.error = true; + component.testId = "foo"; + component.id = "foo-dropdown"; + component.width = "200px"; + component.maxWidth = "400px"; + component.mt = "s"; + component.mr = "m"; + component.mb = "l"; + component.ml = "xl"; + component.ariaLabel = "Label"; + component.ariaLabelledBy = "foo-dropdown-label"; + component.autoComplete = "off"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should bind all web-components attribute", () => { + const el = fixture.debugElement.query(By.css("goa-dropdown")).nativeElement; + expect(el?.getAttribute("leadingicon")).toBe("color-wand"); + expect(el?.getAttribute("mt")).toBe("s"); + expect(el?.getAttribute("mr")).toBe("m"); + expect(el?.getAttribute("mb")).toBe("l"); + expect(el?.getAttribute("ml")).toBe("xl"); + expect(el?.getAttribute("id")).toBe("foo-dropdown"); + expect(el?.getAttribute("filterable")).toBe("true"); + expect(el?.getAttribute("arialabel")).toBe("Label"); + expect(el?.getAttribute("arialabelledby")).toBe("foo-dropdown-label"); + expect(el?.getAttribute("autocomplete")).toBe("off"); + expect(el?.getAttribute("maxwidth")).toBe("400px"); + + // Check options + const dropdownItems = el.querySelectorAll("goa-dropdown-item"); + expect(dropdownItems.length).toBe(3); + const expectedOptions = [ + { label: "Red", value: "red" }, + { label: "Blue", value: "blue" }, + { label: "Yellow", value: "yellow" }, + ]; + expectedOptions.forEach((option, index) => { + expect(dropdownItems[index].getAttribute("name")).toBe(component.name); + }); + }); + + it("should allow for a single selection", fakeAsync(() => { + const onChangeMock = jest.spyOn(component, "onChange"); + component.native = true; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const el = fixture.debugElement.query(By.css("goa-dropdown")).nativeElement; + expect(el).toBeTruthy(); + + fireEvent( + el, + new CustomEvent("_change", { + detail: { name: component.name, value: "yellow" }, + }), + ); + expect(onChangeMock).toHaveBeenCalled(); + })); + + describe("writeValue", () => { + it("should set value attribute when writeValue is called with a value", () => { + const dropdownComponent = fixture.debugElement.query( + By.css("goabx-dropdown"), + ).componentInstance; + const dropdownElement = fixture.debugElement.query( + By.css("goa-dropdown"), + ).nativeElement; + + dropdownComponent.writeValue("red"); + expect(dropdownElement.getAttribute("value")).toBe("red"); + + dropdownComponent.writeValue("blue"); + expect(dropdownElement.getAttribute("value")).toBe("blue"); + }); + + it("should set value attribute to empty string when writeValue is called with null", () => { + const dropdownComponent = fixture.debugElement.query( + By.css("goabx-dropdown"), + ).componentInstance; + const dropdownElement = fixture.debugElement.query( + By.css("goa-dropdown"), + ).nativeElement; + + // First set a value + dropdownComponent.writeValue("red"); + expect(dropdownElement.getAttribute("value")).toBe("red"); + + // Then clear it + dropdownComponent.writeValue(null); + expect(dropdownElement.getAttribute("value")).toBe(""); + }); + + it("should update component value property", () => { + const dropdownComponent = fixture.debugElement.query( + By.css("goabx-dropdown"), + ).componentInstance; + + dropdownComponent.writeValue("yellow"); + expect(dropdownComponent.value).toBe("yellow"); + + dropdownComponent.writeValue(null); + expect(dropdownComponent.value).toBe(null); + }); + }); + + describe("_onChange", () => { + it("should update component value when user selects an option", () => { + const dropdownComponent = fixture.debugElement.query( + By.css("goabx-dropdown"), + ).componentInstance; + const dropdownElement = fixture.debugElement.query( + By.css("goa-dropdown"), + ).nativeElement; + + fireEvent( + dropdownElement, + new CustomEvent("_change", { + detail: { name: component.name, value: "yellow" }, + }), + ); + + expect(dropdownComponent.value).toBe("yellow"); + }); + + it("should update value to null when cleared", () => { + const dropdownComponent = fixture.debugElement.query( + By.css("goabx-dropdown"), + ).componentInstance; + const dropdownElement = fixture.debugElement.query( + By.css("goa-dropdown"), + ).nativeElement; + + // Set initial value + fireEvent( + dropdownElement, + new CustomEvent("_change", { + detail: { name: component.name, value: "red" }, + }), + ); + expect(dropdownComponent.value).toBe("red"); + + // Clear value + fireEvent( + dropdownElement, + new CustomEvent("_change", { + detail: { name: component.name, value: "" }, + }), + ); + expect(dropdownComponent.value).toBe(null); + }); + }); +}); diff --git a/libs/angular-components/src/experimental/dropdown/dropdown.ts b/libs/angular-components/src/experimental/dropdown/dropdown.ts new file mode 100644 index 0000000000..e59151c5be --- /dev/null +++ b/libs/angular-components/src/experimental/dropdown/dropdown.ts @@ -0,0 +1,117 @@ +import { + GoabDropdownOnChangeDetail, + GoabDropdownSize, + GoabIconType, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + booleanAttribute, + forwardRef, + OnInit, + ChangeDetectorRef, + Renderer2, +} from "@angular/core"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CommonModule } from "@angular/common"; +import { GoabControlValueAccessor } from "../base.component"; + +// "disabled", "value", "id" is an exposed property of HTMLInputElement, no need to bind with attr +@Component({ + standalone: true, + selector: "goabx-dropdown", + imports: [CommonModule], + template: ` + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => GoabxDropdown), + }, + ], +}) +export class GoabxDropdown extends GoabControlValueAccessor implements OnInit { + @Input() name?: string; + @Input() ariaLabel?: string; + @Input() ariaLabelledBy?: string; + @Input({ transform: booleanAttribute }) filterable?: boolean; + @Input() leadingIcon?: GoabIconType; + @Input() maxHeight?: string; + @Input({ transform: booleanAttribute }) multiselect?: boolean; + @Input({ transform: booleanAttribute }) native?: boolean; + @Input() placeholder?: string; + @Input() width?: string; + @Input() maxWidth?: string; + @Input() autoComplete?: string; + @Input() size?: GoabDropdownSize = "default"; + /*** + * @deprecated This property has no effect and will be removed in a future version + */ + @Input() relative?: boolean; + @Output() onChange = new EventEmitter(); + + isReady = false; + version = "2"; + + constructor( + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + _onChange(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + // Keep local value in sync with emitted detail + this.value = detail.value || null; + this.onChange.emit(detail); + + this.markAsTouched(); + this.fcChange?.(detail.value || ""); + } +} diff --git a/libs/angular-components/src/experimental/file-upload-card/file-upload-card.spec.ts b/libs/angular-components/src/experimental/file-upload-card/file-upload-card.spec.ts new file mode 100644 index 0000000000..e50d27f97d --- /dev/null +++ b/libs/angular-components/src/experimental/file-upload-card/file-upload-card.spec.ts @@ -0,0 +1,127 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxFileUploadCard } from "./file-upload-card"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { Spacing } from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; +import { fireEvent } from "@testing-library/dom"; + +@Component({ + standalone: true, + imports: [GoabxFileUploadCard], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + template: ` + + `, +}) +class TestGoABFileUploadComponent { + filename = ""; + mt?: Spacing; + mb?: Spacing; + mr?: Spacing; + ml?: Spacing; + size?: number; + type?: string; + progress?: number; + error?: string; + testId?: string; + + onCancel() { + /** do nothing **/ + } + + onDelete() { + /** do nothing **/ + } +} + +describe("GoABFileUploadCard", () => { + let fixture: ComponentFixture; + let component: TestGoABFileUploadComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxFileUploadCard, TestGoABFileUploadComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestGoABFileUploadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + it("should render with base params", fakeAsync(() => { + component.filename = "foo.png"; + component.size = 1e3; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const el = fixture.debugElement.query(By.css("goa-file-upload-card")).nativeElement; + expect(el?.getAttribute("filename")).toBe(component.filename); + expect(el?.getAttribute("size")).toBe("1000"); + })); + it("should render with additional params", fakeAsync(() => { + component.filename = "foo.png"; + component.size = 1e3; + component.type = "image/png"; + component.progress = 23; + component.error = "true"; + component.testId = "foo"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const el = fixture.debugElement.query(By.css("goa-file-upload-card")).nativeElement; + expect(el?.getAttribute("filename")).toBe("foo.png"); + expect(el?.getAttribute("size")).toBe("1000"); + expect(el?.getAttribute("type")).toBe("image/png"); + expect(el?.getAttribute("progress")).toBe("23"); + expect(el?.getAttribute("error")).toBe("true"); + expect(el?.getAttribute("testid")).toBe("foo"); + })); + + it("should dispatch an even when delete is clicked and upload is complete", fakeAsync(() => { + const onCancel = jest.spyOn(component, "onCancel"); + component.filename = "foo.png"; + component.size = 1e3; + component.progress = 23; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const el = fixture.debugElement.query(By.css("goa-file-upload-card")).nativeElement; + fireEvent(el, new CustomEvent("_cancel")); + + expect(onCancel).toHaveBeenCalledTimes(1); + })); + it("should dispatch an event when an error occurs", fakeAsync(() => { + const onDelete = jest.spyOn(component, "onDelete"); + component.filename = "foo.png"; + component.size = 1e3; + component.error = "fail"; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const el = fixture.debugElement.query(By.css("goa-file-upload-card")).nativeElement; + fireEvent(el, new CustomEvent("_delete")); + + expect(onDelete).toHaveBeenCalledTimes(1); + })); +}); diff --git a/libs/angular-components/src/experimental/file-upload-card/file-upload-card.ts b/libs/angular-components/src/experimental/file-upload-card/file-upload-card.ts new file mode 100644 index 0000000000..15efa2af0a --- /dev/null +++ b/libs/angular-components/src/experimental/file-upload-card/file-upload-card.ts @@ -0,0 +1,68 @@ +import { + GoabFileUploadOnCancelDetail, + GoabFileUploadOnDeleteDetail, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + numberAttribute, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-file-upload-card", + template: ` + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class GoabxFileUploadCard implements OnInit { + @Input({ required: true }) filename!: string; + @Input({ transform: numberAttribute }) size?: number; + @Input() type?: string; + @Input({ transform: numberAttribute }) progress?: number; + @Input() error?: string; + @Input() testId?: string; + + @Output() onCancel = new EventEmitter(); + @Output() onDelete = new EventEmitter(); + + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + _onCancel(event: Event) { + this.onCancel.emit({ filename: this.filename, event }); + } + + _onDelete(event: Event) { + this.onDelete.emit({ filename: this.filename, event }); + } +} diff --git a/libs/angular-components/src/experimental/file-upload-input/file-upload-input.spec.ts b/libs/angular-components/src/experimental/file-upload-input/file-upload-input.spec.ts new file mode 100644 index 0000000000..79f58c1564 --- /dev/null +++ b/libs/angular-components/src/experimental/file-upload-input/file-upload-input.spec.ts @@ -0,0 +1,87 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxFileUploadInput } from "./file-upload-input"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabFileUploadInputVariant, Spacing } from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; +import { fireEvent } from "@testing-library/dom"; + +@Component({ + standalone: true, + imports: [GoabxFileUploadInput], + template: ` + + `, +}) +class TestFileUploadInputComponent { + maxFileSize?: string; + accept?: string; + variant: GoabFileUploadInputVariant = "button"; + testId?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + + onSelectFile() { + /** do nothing **/ + } +} + +describe("GoABFileUploadInput", () => { + let fixture: ComponentFixture; + let component: TestFileUploadInputComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxFileUploadInput, TestFileUploadInputComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestFileUploadInputComponent); + component = fixture.componentInstance; + + component.maxFileSize = "10MB"; + component.accept = "image/*"; + component.variant = "dragdrop"; + component.testId = "foo"; + component.mt = "s"; + component.mb = "xs"; + component.mr = "xl"; + component.ml = "l"; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render successfully", () => { + const el = fixture.debugElement.query(By.css("goa-file-upload-input")).nativeElement; + + expect(el?.getAttribute("maxfilesize")).toBe(component.maxFileSize); + expect(el?.getAttribute("accept")).toBe(component.accept); + expect(el?.getAttribute("variant")).toBe(component.variant); + expect(el?.getAttribute("testid")).toBe(component.testId); + expect(el?.getAttribute("mt")).toBe(component.mt); + expect(el?.getAttribute("mb")).toBe(component.mb); + expect(el?.getAttribute("mr")).toBe(component.mr); + expect(el?.getAttribute("ml")).toBe(component.ml); + }); + + it("should handle onSelectFile event", () => { + const onSelectFile = jest.spyOn(component, "onSelectFile"); + const el = fixture.debugElement.query(By.css("goa-file-upload-input")).nativeElement; + fireEvent(el, new CustomEvent("_selectFile", { detail: {} })); + + expect(onSelectFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/angular-components/src/experimental/file-upload-input/file-upload-input.ts b/libs/angular-components/src/experimental/file-upload-input/file-upload-input.ts new file mode 100644 index 0000000000..74f5ff8fcd --- /dev/null +++ b/libs/angular-components/src/experimental/file-upload-input/file-upload-input.ts @@ -0,0 +1,66 @@ +import { + GoabFileUploadInputOnSelectFileDetail, + GoabFileUploadInputVariant, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-file-upload-input", + template: ` + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class GoabxFileUploadInput extends GoabBaseComponent implements OnInit { + @Input() id?: string = ""; + @Input({ required: true }) variant!: GoabFileUploadInputVariant; + @Input() maxFileSize?: string = "5MB"; + @Input() accept?: string; + + @Output() onSelectFile = new EventEmitter(); + + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + _onSelectFile(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + this.onSelectFile.emit(detail); + } +} diff --git a/libs/angular-components/src/experimental/filter-chip/filter-chip.spec.ts b/libs/angular-components/src/experimental/filter-chip/filter-chip.spec.ts new file mode 100644 index 0000000000..6d64ce4afd --- /dev/null +++ b/libs/angular-components/src/experimental/filter-chip/filter-chip.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabChipTheme, Spacing } from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; +import { fireEvent } from "@testing-library/dom"; +import { GoabxFilterChip } from "./filter-chip"; + +@Component({ + standalone: true, + imports: [GoabxFilterChip], + template: ` + + + `, +}) +class TestFilterChipComponent { + error?: boolean; + content?: string; + iconTheme?: GoabChipTheme; + testId?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + + onClick() { + /* do nothing */ + } +} + +describe("GoabxFilterChip", () => { + let fixture: ComponentFixture; + let component: TestFilterChipComponent; + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxFilterChip, TestFilterChipComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestFilterChipComponent); + component = fixture.componentInstance; + + component.error = true; + component.content = "some chip"; + component.testId = "chip-test"; + component.iconTheme = "filled"; + component.mt = "s"; + component.mr = "m"; + component.mb = "l"; + component.ml = "xl"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render properties", () => { + const chipElement = fixture.debugElement.query(By.css("goa-filter-chip")).nativeElement; + expect(chipElement.getAttribute("error")).toBe(`${component.error}`); + expect(chipElement.getAttribute("content")).toBe(component.content); + expect(chipElement.getAttribute("icontheme")).toBe(`${component.iconTheme}`); + expect(chipElement.getAttribute("testid")).toBe(component.testId); + expect(chipElement.getAttribute("mt")).toBe(component.mt); + expect(chipElement.getAttribute("mr")).toBe(component.mr); + expect(chipElement.getAttribute("mb")).toBe(component.mb); + expect(chipElement.getAttribute("ml")).toBe(component.ml); + }); + + it("should allow to handle delete event", fakeAsync(() => { + const onClick = jest.spyOn(component, "onClick"); + const chipElement = fixture.debugElement.query(By.css("goa-filter-chip")).nativeElement; + fireEvent(chipElement, new CustomEvent("_click")); + + expect(onClick).toHaveBeenCalled(); + })); +}); diff --git a/libs/angular-components/src/experimental/filter-chip/filter-chip.ts b/libs/angular-components/src/experimental/filter-chip/filter-chip.ts new file mode 100644 index 0000000000..809ae03263 --- /dev/null +++ b/libs/angular-components/src/experimental/filter-chip/filter-chip.ts @@ -0,0 +1,67 @@ +import { GoabChipTheme, GoabIconType } from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + Output, + EventEmitter, + booleanAttribute, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-filter-chip", + template: ` + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class GoabxFilterChip extends GoabBaseComponent implements OnInit { + @Input({ transform: booleanAttribute }) error?: boolean; + @Input({ transform: booleanAttribute }) deletable?: boolean; + @Input() content?: string = ""; + @Input() iconTheme?: GoabChipTheme; + @Input() secondaryText?: string = ""; + @Input() leadingIcon?: GoabIconType | null = null; + + @Output() onClick = new EventEmitter(); + + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + _onClick() { + this.onClick.emit(); + } +} diff --git a/libs/angular-components/src/experimental/footer-meta-section/footer-meta-section.spec.ts b/libs/angular-components/src/experimental/footer-meta-section/footer-meta-section.spec.ts new file mode 100644 index 0000000000..81377354a2 --- /dev/null +++ b/libs/angular-components/src/experimental/footer-meta-section/footer-meta-section.spec.ts @@ -0,0 +1,44 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxAppFooterMetaSection } from "./footer-meta-section"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { By } from "@angular/platform-browser"; + +@Component({ + standalone: true, + imports: [GoabxAppFooterMetaSection], + template: ` + + Home + + `, +}) +class TestFooterMetaSectionComponent { + /** do nothing **/ +} + +describe("GoABFooterMetaSection", () => { + let fixture: ComponentFixture; + let component: TestFooterMetaSectionComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxAppFooterMetaSection, TestFooterMetaSectionComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestFooterMetaSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const el = fixture.debugElement.query( + By.css("goa-app-footer-meta-section"), + ).nativeElement; + expect(el?.getAttribute("testid")).toBe("foo"); + expect(el?.querySelector("a")).toBeTruthy(); + expect(el?.innerHTML).toContain("Home"); + }); +}); diff --git a/libs/angular-components/src/experimental/footer-meta-section/footer-meta-section.ts b/libs/angular-components/src/experimental/footer-meta-section/footer-meta-section.ts new file mode 100644 index 0000000000..2997cf66e8 --- /dev/null +++ b/libs/angular-components/src/experimental/footer-meta-section/footer-meta-section.ts @@ -0,0 +1,39 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-app-footer-meta-section", + template: ` + + + + `, + styles: [":host { width: 100%; }"], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class GoabxAppFooterMetaSection implements OnInit { + @Input() testId?: string; + /** "slot" is required and must equal to "meta" so it can be rendered in the correct position **/ + @Input({ required: true }) slot!: "meta"; + + isReady = false; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } +} diff --git a/libs/angular-components/src/experimental/footer-nav-section/footer-nav-section.spec.ts b/libs/angular-components/src/experimental/footer-nav-section/footer-nav-section.spec.ts new file mode 100644 index 0000000000..0c4398d64e --- /dev/null +++ b/libs/angular-components/src/experimental/footer-nav-section/footer-nav-section.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxAppFooterNavSection } from "./footer-nav-section"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { By } from "@angular/platform-browser"; + +@Component({ + standalone: true, + imports: [GoabxAppFooterNavSection], + template: ` + +

Testing footer

+
+ `, +}) +class TestFooterNavSectionComponent { + testId?: string; + heading?: string; + maxColumnCount?: number; +} + +describe("GoABAppFooterNavSection", () => { + let fixture: ComponentFixture; + let component: TestFooterNavSectionComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxAppFooterNavSection, TestFooterNavSectionComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestFooterNavSectionComponent); + component = fixture.componentInstance; + + component.testId = "foo"; + component.heading = "Footer heading"; + component.maxColumnCount = 3; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render properties", () => { + const el = fixture.debugElement.query( + By.css("goa-app-footer-nav-section"), + ).nativeElement; + expect(el?.getAttribute("testid")).toBe(component.testId); + expect(el?.getAttribute("heading")).toBe(component.heading); + expect(el?.getAttribute("maxcolumncount")).toBe(`${component.maxColumnCount}`); + }); +}); diff --git a/libs/angular-components/src/experimental/footer-nav-section/footer-nav-section.ts b/libs/angular-components/src/experimental/footer-nav-section/footer-nav-section.ts new file mode 100644 index 0000000000..57486667ff --- /dev/null +++ b/libs/angular-components/src/experimental/footer-nav-section/footer-nav-section.ts @@ -0,0 +1,46 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-app-footer-nav-section", + template: ` + + + + `, + styles: [":host { width: 100%; }"], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class GoabxAppFooterNavSection implements OnInit { + @Input() heading?: string; + @Input() maxColumnCount? = 1; + @Input() testId?: string; + /** "slot" is required and must equal to "nav" so it can be rendered in the correct position **/ + @Input({ required: true }) slot!: "nav"; + + isReady = false; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } +} diff --git a/libs/angular-components/src/experimental/footer/footer.spec.ts b/libs/angular-components/src/experimental/footer/footer.spec.ts new file mode 100644 index 0000000000..8870c9ce29 --- /dev/null +++ b/libs/angular-components/src/experimental/footer/footer.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabxAppFooter } from "../footer/footer"; +import { By } from "@angular/platform-browser"; + +@Component({ + standalone: true, + imports: [GoabxAppFooter], + template: ` +
This is the nav content
+
This is the meta content
+
`, +}) +class TestFooterComponent { + maxContentWidth?: string; +} + +describe("GoABFooter", () => { + let fixture: ComponentFixture; + let component: TestFooterComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxAppFooter, TestFooterComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestFooterComponent); + component = fixture.componentInstance; + component.maxContentWidth = "100%"; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render and set the props correctly", () => { + const footerElement = fixture.debugElement.query( + By.css("goa-app-footer"), + ).nativeElement; + expect(footerElement.getAttribute("maxcontentwidth")).toBe("100%"); + const navContent = footerElement.querySelector("[slot='nav']"); + expect(navContent.textContent).toContain("This is the nav content"); + const metaContent = footerElement.querySelector("[slot='meta']"); + expect(metaContent.textContent).toContain("This is the meta content"); + }); +}); diff --git a/libs/angular-components/src/experimental/footer/footer.ts b/libs/angular-components/src/experimental/footer/footer.ts new file mode 100644 index 0000000000..a7aebd5425 --- /dev/null +++ b/libs/angular-components/src/experimental/footer/footer.ts @@ -0,0 +1,47 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-app-footer", + template: ` + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class GoabxAppFooter implements OnInit { + @Input() maxContentWidth?: string; + @Input() testId?: string; + @Input() url?: string; + + isReady = false; + version = 2; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } +} diff --git a/libs/angular-components/src/experimental/form-item/form-item-slot.ts b/libs/angular-components/src/experimental/form-item/form-item-slot.ts new file mode 100644 index 0000000000..3ac193156a --- /dev/null +++ b/libs/angular-components/src/experimental/form-item/form-item-slot.ts @@ -0,0 +1,16 @@ +import { Component, Input } from "@angular/core"; + +@Component({ + standalone: true, + selector: "goabx-form-item-slot", + template: ``, +}) +/** + * This component is used to define the slot for the form item component. + * We need to use a separate component with a required attribute `slot` because + * svelte component renders based on the `slot` of the wrapper component (which is `div` before) + * // similar to app-footer-meta-section & app-footer-nav-section + */ +export class GoabxFormItemSlot { + @Input({ required: true }) slot!: "helptext" | "error"; +} diff --git a/libs/angular-components/src/experimental/form-item/form-item.spec.ts b/libs/angular-components/src/experimental/form-item/form-item.spec.ts new file mode 100644 index 0000000000..94ff893f76 --- /dev/null +++ b/libs/angular-components/src/experimental/form-item/form-item.spec.ts @@ -0,0 +1,109 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxFormItem } from "./form-item"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabFormItemRequirement, Spacing } from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; +import { GoabxFormItemSlot } from "./form-item-slot"; +import { CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + imports: [GoabxFormItem, GoabxFormItemSlot, CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + template: ` + + + This is an error slot + This is a helpText slot + + `, +}) +class TestFormItemComponent { + label?: string; + requirement?: GoabFormItemRequirement; + error?: string; + helpText?: string; + id?: string; + testId?: string; + errorSlot?: boolean; + helpTextSlot?: boolean; + mt?: Spacing; + mb?: Spacing; + mr?: Spacing; + ml?: Spacing; +} + +describe("GoABFormItem", () => { + let fixture: ComponentFixture; + let component: TestFormItemComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxFormItem, TestFormItemComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestFormItemComponent); + component = fixture.componentInstance; + + component.label = "First name"; + component.requirement = "optional"; + component.id = "firstName"; + component.testId = "foo"; + component.mt = "s"; + component.mb = "l"; + component.ml = "xl"; + component.mr = "m"; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render with properties", () => { + component.error = "This is an error"; + component.helpText = "this is some helptext"; + fixture.detectChanges(); + + const el = fixture.debugElement.query(By.css("goa-form-item")).nativeElement; + + expect(el?.getAttribute("label")).toBe(component.label); + expect(el?.getAttribute("requirement")).toBe(component.requirement); + expect(el?.getAttribute("error")).toBe(component.error); + expect(el?.getAttribute("helptext")).toBe(component.helpText); + expect(el?.getAttribute("id")).toBe(component.id); + expect(el?.getAttribute("testid")).toBe(component.testId); + expect(el?.getAttribute("maxwidth")).toBe("480px"); + expect(el?.getAttribute("mt")).toBe(component.mt); + expect(el?.getAttribute("mb")).toBe(component.mb); + expect(el?.getAttribute("mr")).toBe(component.mr); + expect(el?.getAttribute("ml")).toBe(component.ml); + + // Children is rendered + expect(el?.querySelector("input[data-testid='foo']")).toBeTruthy(); + }); + + it("should render error and helpText slot", () => { + component.errorSlot = true; + component.helpTextSlot = true; + fixture.detectChanges(); + + const el = fixture.debugElement.query(By.css("goa-form-item")).nativeElement; + expect(el?.querySelector("[slot='error']")).toBeTruthy(); + expect(el?.innerHTML).toContain("This is an error slot"); + expect(el?.querySelector("[slot='helptext']")).toBeTruthy(); + expect(el?.innerHTML).toContain("This is a helpText slot"); + expect(el?.querySelector("input[data-testid='foo']")).toBeTruthy(); + }); +}); diff --git a/libs/angular-components/src/experimental/form-item/form-item.ts b/libs/angular-components/src/experimental/form-item/form-item.ts new file mode 100644 index 0000000000..acf0feb4b2 --- /dev/null +++ b/libs/angular-components/src/experimental/form-item/form-item.ts @@ -0,0 +1,79 @@ +import { + GoabFormItemLabelSize, + GoabFormItemRequirement, +} from "@abgov/ui-components-common"; +import { + Component, + CUSTOM_ELEMENTS_SCHEMA, + Input, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; +import { GoabFormItemType } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + selector: "goabx-form-item", + template: ` + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class GoabxFormItem extends GoabBaseComponent implements OnInit { + @Input() label?: string; + @Input() labelSize?: GoabFormItemLabelSize; + @Input() helpText?: string; + @Input() error?: string; + @Input() requirement?: GoabFormItemRequirement; + @Input() maxWidth?: string; + @Input() id?: string; + @Input() type?: GoabFormItemType = ""; + /** + * Public form: to arrange fields in the summary + */ + @Input() publicFormSummaryOrder?: number; + /** + * Public form: allow to override the label value within the form-summary to provide a shorter description of the value + */ + @Input() name?: string; + + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } +} diff --git a/libs/angular-components/src/experimental/index.ts b/libs/angular-components/src/experimental/index.ts index 2c48a5595b..8d879f3c10 100644 --- a/libs/angular-components/src/experimental/index.ts +++ b/libs/angular-components/src/experimental/index.ts @@ -1,3 +1,32 @@ +export * from "./badge/badge"; +export * from "./button/button"; +export * from "./calendar/calendar"; +export * from "./callout/callout"; +export * from "./checkbox/checkbox"; +export * from "./date-picker/date-picker"; +export * from "./drawer/drawer"; +export * from "./dropdown/dropdown"; +export * from "./dropdown-item/dropdown-item"; +export * from "./file-upload-card/file-upload-card"; +export * from "./file-upload-input/file-upload-input"; +export * from "./filter-chip/filter-chip"; +export * from "./form-item/form-item"; +export * from "./footer/footer"; +export * from "./footer-meta-section/footer-meta-section"; +export * from "./footer-nav-section/footer-nav-section"; +export * from "./input/input"; +export * from "./link/link"; +export * from "./modal/modal"; +export * from "./notification/notification"; +export * from "./pagination/pagination"; +export * from "./radio-group/radio-group"; +export * from "./radio-item/radio-item"; +export * from "./side-menu/side-menu"; +export * from "./side-menu-group/side-menu-group"; +export * from "./side-menu-heading/side-menu-heading"; +export * from "./table/table"; +export * from "./textarea/textarea"; +export * from "./tabs/tabs"; export * from "./work-side-menu/work-side-menu"; export * from "./work-side-menu-item/work-side-menu-item"; export * from "./work-side-menu-group/work-side-menu-group"; diff --git a/libs/angular-components/src/experimental/input/input.spec.ts b/libs/angular-components/src/experimental/input/input.spec.ts new file mode 100644 index 0000000000..304761937a --- /dev/null +++ b/libs/angular-components/src/experimental/input/input.spec.ts @@ -0,0 +1,370 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxInput } from "./input"; +import { Component, CUSTOM_ELEMENTS_SCHEMA, TemplateRef } from "@angular/core"; +import { + GoabIconType, + GoabInputAutoCapitalize, + GoabInputOnChangeDetail, + GoabInputType, + Spacing, +} from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; +import { fireEvent } from "@testing-library/dom"; + +@Component({ + standalone: true, + imports: [GoabxInput], + template: ` + + +
Leading Content
+
+ +
Trailing Content
+
+
+ `, +}) +class TestInputComponent { + name = "foo"; + id?: string; + debounce?: number; + disabled?: boolean; + autoCapitalize?: GoabInputAutoCapitalize; + autoComplete?: string; + placeholder?: string; + leadingIcon?: GoabIconType; + trailingIcon?: GoabIconType; + variant?: string; + focused?: boolean; + readonly?: boolean; + error?: boolean; + width?: string; + prefix?: string; + suffix?: string; + testId?: string; + ariaLabel?: string; + maxLength?: number; + value?: string | null = ""; + min?: number; + max?: number; + step?: number; + type?: GoabInputType = "text"; + ariaLabelledBy?: string; + textAlign?: "left" | "right"; + mt?: Spacing; + mr?: Spacing; + mb?: Spacing; + ml?: Spacing; + leadingContent!: string | TemplateRef; + trailingContent!: string | TemplateRef; + + onTrailingIconClick() { + /** do nothing **/ + } + + onFocus() { + /** do nothing **/ + } + + onBlur() { + /** do nothing **/ + } + + onKeyPress() { + /** do nothing **/ + } + + onChange(event: GoabInputOnChangeDetail) { + /** do nothing **/ + } +} + +describe("GoABInput", () => { + let fixture: ComponentFixture; + let component: TestInputComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestInputComponent, GoabxInput], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestInputComponent); + component = fixture.componentInstance; + + // assign values + component.value = "bar"; + component.id = "foo"; + component.leadingIcon = "search"; + component.trailingIcon = "close"; + component.autoCapitalize = "on"; + component.autoComplete = "off"; + component.variant = "bare"; + component.disabled = true; + component.readonly = true; + component.focused = true; + component.placeholder = "placeholder"; + component.prefix = "foo"; + component.suffix = "bar"; + component.testId = "test-id"; + component.debounce = 1000; + component.mt = "s"; + component.mr = "m"; + component.mb = "l"; + component.ml = "xl"; + component.maxLength = 10; + component.prefix = "$"; + component.suffix = "items"; + component.ariaLabel = "foo input"; + component.ariaLabelledBy = "foo"; + component.min = 0; + component.max = 100; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const input = fixture.debugElement.query(By.css("goa-input")).nativeElement; + expect(input?.getAttribute("name")).toBe(component.name); + expect(input?.getAttribute("value")).toBe(component.value); + expect(input?.getAttribute("type")).toBe(component.type); + expect(input?.getAttribute("id")).toBe(component.id); + expect(input?.getAttribute("leadingicon")).toBe(component.leadingIcon); + expect(input?.getAttribute("trailingicon")).toBe(component.trailingIcon); + expect(input?.getAttribute("autocapitalize")).toBe(component.autoCapitalize); + expect(input?.getAttribute("autocomplete")).toBe(component.autoComplete); + expect(input?.getAttribute("variant")).toBe(component.variant); + expect(input?.getAttribute("focused")).toBe(`${component.focused}`); + expect(input?.getAttribute("placeholder")).toBe(component.placeholder); + expect(input?.getAttribute("prefix")).toBe(component.prefix); + expect(input?.getAttribute("suffix")).toBe(component.suffix); + expect(input?.getAttribute("data-testid")).toBe(component.testId); + expect(input?.getAttribute("debounce")).toBe(`${component.debounce}`); + expect(input?.getAttribute("mt")).toBe(component.mt); + expect(input?.getAttribute("mr")).toBe(component.mr); + expect(input?.getAttribute("mb")).toBe(component.mb); + expect(input?.getAttribute("ml")).toBe(component.ml); + expect(input?.getAttribute("maxlength")).toBe(`${component.maxLength}`); + expect(input?.getAttribute("arialabel")).toBe(component.ariaLabel); + expect(input?.getAttribute("arialabelledby")).toBe(component.ariaLabelledBy); + expect(input?.getAttribute("min")).toBe(`${component.min}`); + expect(input?.getAttribute("max")).toBe(`${component.max}`); + }); + + describe("Text Alignment", () => { + it("passes textAlign prop through to web component", fakeAsync(() => { + const testFixture = TestBed.createComponent(TestInputComponent); + const testComponent = testFixture.componentInstance; + testComponent.name = "test"; + testComponent.textAlign = "right"; + testFixture.detectChanges(); + tick(); + testFixture.detectChanges(); + + const input = testFixture.debugElement.query(By.css("goa-input")).nativeElement; + expect(input?.getAttribute("textalign")).toBe("right"); + + testComponent.textAlign = "left"; + testFixture.detectChanges(); + + expect(input?.getAttribute("textalign")).toBe("left"); + })); + }); + + it("should handle onChange event", () => { + const validateOnChange = jest.spyOn(component, "onChange"); + + const input = fixture.debugElement.query(By.css("goa-input")).nativeElement; + + fireEvent( + input, + new CustomEvent("_change", { detail: { name: "foo", value: "new value" } }), + ); + + expect(validateOnChange).toBeCalledWith( + expect.objectContaining({ + name: "foo", + value: "new value", + event: expect.any(Event), + }), + ); + }); + + it("should handle onFocus event", () => { + const validateOnFocus = jest.spyOn(component, "onFocus"); + + const input = fixture.debugElement.query(By.css("goa-input")).nativeElement; + + fireEvent(input, new CustomEvent("_focus")); + + expect(validateOnFocus).toBeCalled(); + }); + + it("should handle onBlur event", () => { + const validateOnBlur = jest.spyOn(component, "onBlur"); + + const input = fixture.debugElement.query(By.css("goa-input")).nativeElement; + + fireEvent(input, new CustomEvent("_blur")); + + expect(validateOnBlur).toBeCalled(); + }); + + it("should handle onKeyPress event", () => { + const validateOnKeyPress = jest.spyOn(component, "onKeyPress"); + + const input = fixture.debugElement.query(By.css("goa-input")).nativeElement; + + fireEvent(input, new CustomEvent("_keyPress")); + + expect(validateOnKeyPress).toBeCalled(); + }); + + it("should render leading and trailing content", () => { + const input = fixture.debugElement.query(By.css("goa-input")); + const leadingContent = input.nativeElement.querySelector("[slot='leadingContent']"); + const trailingContent = input.nativeElement.querySelector("[slot='trailingContent']"); + + expect(leadingContent).toBeTruthy(); + expect(leadingContent.textContent).toContain("Leading Content"); + + expect(trailingContent).toBeTruthy(); + expect(trailingContent.textContent).toContain("Trailing Content"); + }); + + describe("writeValue", () => { + it("should set value attribute when writeValue is called", () => { + const inputComponent = fixture.debugElement.query( + By.css("goabx-input"), + ).componentInstance; + const inputElement = fixture.debugElement.query(By.css("goa-input")).nativeElement; + + inputComponent.writeValue("new value"); + expect(inputElement.getAttribute("value")).toBe("new value"); + + inputComponent.writeValue("another value"); + expect(inputElement.getAttribute("value")).toBe("another value"); + }); + + it("should set value attribute to empty string when writeValue is called with null or empty", () => { + const inputComponent = fixture.debugElement.query( + By.css("goabx-input"), + ).componentInstance; + const inputElement = fixture.debugElement.query(By.css("goa-input")).nativeElement; + + // First set a value + inputComponent.writeValue("some value"); + expect(inputElement.getAttribute("value")).toBe("some value"); + + // Then clear it with null + inputComponent.writeValue(null); + expect(inputElement.getAttribute("value")).toBe(""); + + // Set again and clear with undefined + inputComponent.writeValue("test"); + inputComponent.writeValue(undefined); + expect(inputElement.getAttribute("value")).toBe(""); + + // Set again and clear with empty string + inputComponent.writeValue("test2"); + inputComponent.writeValue(""); + expect(inputElement.getAttribute("value")).toBe(""); + }); + + it("should update component value property", () => { + const inputComponent = fixture.debugElement.query( + By.css("goabx-input"), + ).componentInstance; + + inputComponent.writeValue("updated"); + expect(inputComponent.value).toBe("updated"); + + inputComponent.writeValue(null); + expect(inputComponent.value).toBe(null); + }); + }); +}); + +@Component({ + standalone: true, + imports: [GoabxInput], + template: ` + + `, +}) +class TestStringContentComponent { + leadingContent = "String Leading Content"; + trailingContent = "String Trailing Content"; +} + +describe("GoabxInput with string content", () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestStringContentComponent, GoabxInput], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestStringContentComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render string leadingContent and trailingContent", () => { + const input = fixture.debugElement.query(By.css("goa-input")); + const leadingContent = input.nativeElement.querySelector("[slot='leadingContent']"); + const trailingContent = input.nativeElement.querySelector("[slot='trailingContent']"); + + expect(leadingContent).toBeTruthy(); + expect(leadingContent.textContent).toContain("String Leading Content"); + + expect(trailingContent).toBeTruthy(); + expect(trailingContent.textContent).toContain("String Trailing Content"); + }); +}); diff --git a/libs/angular-components/src/experimental/input/input.ts b/libs/angular-components/src/experimental/input/input.ts new file mode 100644 index 0000000000..1a352983e5 --- /dev/null +++ b/libs/angular-components/src/experimental/input/input.ts @@ -0,0 +1,218 @@ +import { + GoabIconType, + GoabInputAutoCapitalize, + GoabInputOnBlurDetail, + GoabInputOnChangeDetail, + GoabInputOnFocusDetail, + GoabInputOnKeyPressDetail, + GoabInputSize, + GoabInputType, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + forwardRef, + OnInit, + booleanAttribute, + numberAttribute, + TemplateRef, + ChangeDetectorRef, + Renderer2, +} from "@angular/core"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; +import { GoabControlValueAccessor } from "../base.component"; +import { NgIf, NgTemplateOutlet, CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-input", + imports: [NgIf, NgTemplateOutlet, CommonModule], + template: ` + +
+ + + + + {{ getLeadingContentAsString() }} + +
+ + + +
+ + + + + {{ getTrailingContentAsString() }} + +
+
+ `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => GoabxInput), + }, + ], +}) +export class GoabxInput extends GoabControlValueAccessor implements OnInit { + @Input() type?: GoabInputType = "text"; + @Input() name?: string; + @Input({ transform: numberAttribute }) debounce?: number; + @Input() autoCapitalize?: GoabInputAutoCapitalize; + @Input() autoComplete?: string; + @Input() placeholder?: string; + @Input() leadingIcon?: GoabIconType; + @Input() trailingIcon?: GoabIconType; + @Input() variant?: string; + @Input({ transform: booleanAttribute }) focused?: boolean; + @Input({ transform: booleanAttribute }) readonly?: boolean; + @Input() width?: string; + @Input() prefix?: string; + @Input() suffix?: string; + @Input() ariaLabel?: string; + @Input({ transform: numberAttribute }) maxLength?: number; + @Input() min?: string | number; + @Input() max?: string | number; + @Input({ transform: numberAttribute }) step?: number; + @Input() ariaLabelledBy?: string; + @Input() trailingIconAriaLabel?: string; + @Input() textAlign?: "left" | "right" = "left"; + @Input() leadingContent!: string | TemplateRef; + @Input() trailingContent!: string | TemplateRef; + @Input() size?: GoabInputSize = "default"; + + @Output() onTrailingIconClick = new EventEmitter(); + @Output() onFocus = new EventEmitter(); + @Output() onBlur = new EventEmitter(); + @Output() onKeyPress = new EventEmitter(); + @Output() onChange = new EventEmitter(); + + isReady = false; + version = "2"; + handleTrailingIconClick = false; + + constructor( + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); + } + ngOnInit() { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + + this.handleTrailingIconClick = this.onTrailingIconClick.observed; + if (typeof this.value === "number") { + console.warn("For numeric values use goab-input-number."); + } + } + + _onTrailingIconClick(_: Event) { + if (this.handleTrailingIconClick) { + this.onTrailingIconClick.emit(); + } + } + + _onChange(e: Event) { + this.markAsTouched(); + const detail = { ...(e as CustomEvent).detail, event: e }; + this.onChange.emit(detail); + + this.fcChange?.(detail.value); + } + + _onKeyPress(e: Event) { + this.markAsTouched(); + const detail = { ...(e as CustomEvent).detail, event: e }; + this.onKeyPress.emit(detail); + + this.fcTouched?.(); + } + + _onFocus(e: Event) { + this.markAsTouched(); + const detail = { ...(e as CustomEvent).detail, event: e }; + this.onFocus.emit(detail); + } + + _onBlur(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + this.onBlur.emit(detail); + } + + getLeadingContentAsString(): string { + return this.leadingContent instanceof TemplateRef ? "" : this.leadingContent; + } + + getLeadingContentAsTemplate(): TemplateRef | null { + if (!this.leadingContent) return null; + return this.leadingContent instanceof TemplateRef ? this.leadingContent : null; + } + + getTrailingContentAsString(): string { + return this.trailingContent instanceof TemplateRef ? "" : this.trailingContent; + } + + getTrailingContentAsTemplate(): TemplateRef | null { + if (!this.trailingContent) return null; + return this.trailingContent instanceof TemplateRef ? this.trailingContent : null; + } +} diff --git a/libs/angular-components/src/experimental/link/link.spec.ts b/libs/angular-components/src/experimental/link/link.spec.ts new file mode 100644 index 0000000000..faa4de55fb --- /dev/null +++ b/libs/angular-components/src/experimental/link/link.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxLink } from "./link"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabIconType, Spacing } from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; + +@Component({ + standalone: true, + imports: [GoabxLink], + template: ` + + Test Link + + `, +}) +class TestLinkComponent { + leadingIcon?: GoabIconType; + trailingIcon?: GoabIconType; + testId?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; +} + +describe("GoABLink", () => { + let fixture: ComponentFixture; + let component: TestLinkComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxLink, TestLinkComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestLinkComponent); + component = fixture.componentInstance; + component.leadingIcon = "add"; + component.trailingIcon = "archive"; + component.testId = "test-id"; + component.mt = "xs" as Spacing; + component.mb = "m" as Spacing; + component.ml = "l" as Spacing; + component.mr = "xl" as Spacing; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render and set the props correctly", () => { + const linkElement = fixture.debugElement.query(By.css("goa-link")).nativeElement; + expect(linkElement.getAttribute("leadingicon")).toBe("add"); + expect(linkElement.getAttribute("trailingicon")).toBe("archive"); + expect(linkElement.getAttribute("testid")).toBe("test-id"); + expect(linkElement.getAttribute("mt")).toBe("xs"); + expect(linkElement.getAttribute("mb")).toBe("m"); + expect(linkElement.getAttribute("ml")).toBe("l"); + expect(linkElement.getAttribute("mr")).toBe("xl"); + }); +}); diff --git a/libs/angular-components/src/experimental/link/link.ts b/libs/angular-components/src/experimental/link/link.ts new file mode 100644 index 0000000000..76cffb9441 --- /dev/null +++ b/libs/angular-components/src/experimental/link/link.ts @@ -0,0 +1,66 @@ +import { + GoabIconType, + GoabLinkColor, + GoabLinkSize, + Spacing, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-link", + template: ` + + + + `, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxLink implements OnInit { + isReady = false; + @Input() leadingIcon?: GoabIconType; + @Input() trailingIcon?: GoabIconType; + @Input() testId?: string; + @Input() action?: string; + @Input() color?: GoabLinkColor = "interactive"; + @Input() size?: GoabLinkSize = "medium"; + @Input() actionArg?: string; + @Input() actionArgs?: Record; + @Input() mt?: Spacing; + @Input() mb?: Spacing; + @Input() ml?: Spacing; + @Input() mr?: Spacing; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } + + protected readonly JSON = JSON; +} diff --git a/libs/angular-components/src/experimental/modal/modal.spec.ts b/libs/angular-components/src/experimental/modal/modal.spec.ts new file mode 100644 index 0000000000..8877c89c62 --- /dev/null +++ b/libs/angular-components/src/experimental/modal/modal.spec.ts @@ -0,0 +1,87 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxModal } from "./modal"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { + GoabModalCalloutVariant, + GoabModalRole, + GoabModalTransition, +} from "@abgov/ui-components-common"; +import { By } from "@angular/platform-browser"; + +@Component({ + standalone: true, + imports: [GoabxModal], + template: ` + + +

Heading

+
+ + + + {{ content }} +
+ `, +}) +class TestModalComponent { + open = true; + maxWidth = "500px"; + callOutVariant = "information" as GoabModalCalloutVariant; + role = "alertdialog" as GoabModalRole; + testId = "testId"; + closable = true; + transition = "fast" as GoabModalTransition; + heading = "Modal Heading"; + actions = "Close"; + content = "Modal Content"; + + onClose() { + /* do nothing */ + } +} + +describe("GoABModal", () => { + let fixture: ComponentFixture; + let component: TestModalComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestModalComponent, GoabxModal], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const modal = fixture.debugElement.query(By.css("goa-modal")).nativeElement; + + const actionContent = modal?.querySelector("[slot='actions']"); + expect(actionContent?.querySelector("button")?.textContent).toContain("Close"); + const headingContent = modal?.querySelector("[slot='heading']"); + expect(headingContent?.textContent).toContain("Heading"); + expect(modal?.getAttribute("open")).toBe(`${component.open}`); + expect(modal?.getAttribute("maxwidth")).toBe(component.maxWidth); + expect(modal?.getAttribute("closable")).toBe(`${component.closable}`); + expect(modal?.textContent).toContain(component.content); + expect(modal?.getAttribute("calloutvariant")).toBe(component.callOutVariant); + expect(modal?.getAttribute("testid")).toBe(component.testId); + expect(modal?.getAttribute("transition")).toBe(component.transition); + expect(modal?.getAttribute("role")).toBe(component.role); + }); +}); diff --git a/libs/angular-components/src/experimental/modal/modal.ts b/libs/angular-components/src/experimental/modal/modal.ts new file mode 100644 index 0000000000..565711a289 --- /dev/null +++ b/libs/angular-components/src/experimental/modal/modal.ts @@ -0,0 +1,92 @@ +import { + GoabModalCalloutVariant, + GoabModalTransition, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + TemplateRef, + booleanAttribute, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { NgTemplateOutlet, CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-modal", + imports: [NgTemplateOutlet, CommonModule], + template: ` + +
+ +
+ + + +
+ +
+
+ `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxModal implements OnInit { + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + @Input() calloutVariant?: GoabModalCalloutVariant; + @Input({ transform: booleanAttribute }) open?: boolean; + @Input() maxWidth?: string; + @Input() closable = false; + @Input() transition?: GoabModalTransition; + @Input() testId?: string; + /** + * @deprecated The role property is deprecated and will be removed in a future version. + * The modal will always use role="dialog". + */ + @Input() role?: string; + @Input() heading!: string | TemplateRef; + @Input() actions!: TemplateRef; + + @Output() onClose = new EventEmitter(); + + getHeadingAsString(): string { + return this.heading instanceof TemplateRef ? "" : this.heading; + } + + getHeadingAsTemplate(): TemplateRef | null { + if (!this.heading) return null; + return this.heading instanceof TemplateRef ? this.heading : null; + } + + _onClose() { + this.onClose.emit(); + } +} diff --git a/libs/angular-components/src/experimental/notification/notification.spec.ts b/libs/angular-components/src/experimental/notification/notification.spec.ts new file mode 100644 index 0000000000..1fd89d3cf8 --- /dev/null +++ b/libs/angular-components/src/experimental/notification/notification.spec.ts @@ -0,0 +1,67 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxNotification } from "./notification"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { fireEvent } from "@testing-library/dom"; +import { GoabAriaLiveType, GoabNotificationType } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + imports: [GoabxNotification], + template: ` + + Information to the user goes in the content + + `, +}) +class TestNotificationComponent { + type = "information" as GoabNotificationType; + ariaLive = "assertive" as GoabAriaLiveType; + maxContentWidth = "100px"; + testId = "testId"; + onDismiss = () => { + /** do something */ + }; +} + +describe("GoABNotification", () => { + let fixture: ComponentFixture; + let component: TestNotificationComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestNotificationComponent, GoabxNotification], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestNotificationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render notification banner", () => { + const el = fixture.nativeElement.querySelector("goa-notification"); + expect(el).toBeTruthy(); + + expect(el?.getAttribute("type")).toEqual(component.type); + expect(el?.getAttribute("arialive")).toEqual(component.ariaLive); + expect(el?.getAttribute("maxcontentwidth")).toEqual(component.maxContentWidth); + expect(el?.getAttribute("testid")).toEqual(component.testId); + expect(el?.textContent).toContain("Information to the user goes in the content"); + }); + + it("should trigger on notification banner dismiss", () => { + const onDismissSpy = jest.spyOn(component, "onDismiss"); + const el = fixture.nativeElement.querySelector("goa-notification"); + fireEvent(el, new CustomEvent("_dismiss")); + + expect(onDismissSpy).toHaveBeenCalled(); + }); +}); diff --git a/libs/angular-components/src/experimental/notification/notification.ts b/libs/angular-components/src/experimental/notification/notification.ts new file mode 100644 index 0000000000..7ab3380484 --- /dev/null +++ b/libs/angular-components/src/experimental/notification/notification.ts @@ -0,0 +1,63 @@ +import { + GoabAriaLiveType, + GoabNotificationEmphasis, + GoabNotificationType, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + OnInit, + ChangeDetectorRef, + booleanAttribute, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-notification", + template: ` + + + + `, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxNotification implements OnInit { + isReady = false; + version = "2"; + @Input() type?: GoabNotificationType = "information"; + @Input() ariaLive?: GoabAriaLiveType; + @Input() maxContentWidth?: string; + @Input() emphasis?: GoabNotificationEmphasis = "high"; + @Input({ transform: booleanAttribute }) compact?: boolean; + @Input() testId?: string; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } + + @Output() onDismiss = new EventEmitter(); + + _onDismiss() { + this.onDismiss.emit(); + } +} diff --git a/libs/angular-components/src/experimental/pagination/pagination.spec.ts b/libs/angular-components/src/experimental/pagination/pagination.spec.ts new file mode 100644 index 0000000000..78a782b9f2 --- /dev/null +++ b/libs/angular-components/src/experimental/pagination/pagination.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxPagination } from "./pagination"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabPaginationOnChangeDetail, GoabPaginationVariant, Spacing } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + imports: [GoabxPagination], + template: ` + + + ` +}) +class TestPaginationComponent { + itemCount = 100; + pageNumber = 1; + perPageCount = 20; + variant = "all" as GoabPaginationVariant; + mt = "s" as Spacing; + mb = "m" as Spacing; + ml = "l" as Spacing; + mr = "xl" as Spacing; + + onChange = (event: GoabPaginationOnChangeDetail) => {/** do nothing **/}; +} + +describe("GoABPagination", () => { + let fixture: ComponentFixture; + let component: TestPaginationComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxPagination, TestPaginationComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(TestPaginationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render successfully", () => { + const el = fixture.nativeElement.querySelector("goa-pagination"); + + expect(el.getAttribute("itemcount")).toBe(`${component.itemCount}`); + expect(el.getAttribute("pagenumber")).toBe(`${component.pageNumber}`); + expect(el.getAttribute("perpagecount")).toBe(`${component.perPageCount}`); + expect(el.getAttribute("variant")).toBe(component.variant); + expect(el.getAttribute("mt")).toBe(component.mt); + expect(el.getAttribute("mb")).toBe(component.mb); + expect(el.getAttribute("ml")).toBe(component.ml); + expect(el.getAttribute("mr")).toBe( component.mr); + }); + + it("should handle the onChange event", () => { + const onChangeSpy = jest.spyOn(component, "onChange"); + + const el = fixture.nativeElement.querySelector("goa-pagination"); + el.dispatchEvent(new CustomEvent("_change", { detail: { page: 2 } })); + + expect(onChangeSpy).toHaveBeenCalledWith({ page: 2 }); + }); +}); diff --git a/libs/angular-components/src/experimental/pagination/pagination.ts b/libs/angular-components/src/experimental/pagination/pagination.ts new file mode 100644 index 0000000000..372622aa1c --- /dev/null +++ b/libs/angular-components/src/experimental/pagination/pagination.ts @@ -0,0 +1,66 @@ +import { + GoabPaginationOnChangeDetail, + GoabPaginationVariant, + Spacing, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-pagination", + template: ` + + + `, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxPagination extends GoabBaseComponent implements OnInit { + isReady = false; + version = "2"; + @Input({ required: true }) itemCount!: number; + @Input({ required: true }) pageNumber!: number; + @Input() perPageCount?: number = 10; + @Input() variant?: GoabPaginationVariant = "all"; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } + + @Output() onChange = new EventEmitter(); + + _onChange(e: Event) { + const detail = (e as CustomEvent).detail; + this.onChange.emit(detail); + } +} diff --git a/libs/angular-components/src/experimental/radio-group/radio-group.spec.ts b/libs/angular-components/src/experimental/radio-group/radio-group.spec.ts new file mode 100644 index 0000000000..9fc7169f2b --- /dev/null +++ b/libs/angular-components/src/experimental/radio-group/radio-group.spec.ts @@ -0,0 +1,243 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxRadioGroup } from "./radio-group"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { + GoabRadioGroupOnChangeDetail, + GoabRadioGroupOrientation, + Spacing, +} from "@abgov/ui-components-common"; +import { GoabxRadioItem } from "../radio-item/radio-item"; +import { fireEvent } from "@testing-library/dom"; +import { By } from "@angular/platform-browser"; +import { CommonModule } from "@angular/common"; + +interface RadioOption { + text: string; + value: string; + description?: string; + isDescriptionSlot?: boolean; +} + +@Component({ + standalone: true, + imports: [GoabxRadioGroup, GoabxRadioItem, CommonModule], + template: ` + + + {{ option.text }} +
+ {{ option.description }} +
+
+
+ `, +}) +class TestRadioGroupComponent { + name?: string; + value?: string; + disabled?: boolean; + orientation?: GoabRadioGroupOrientation; + error?: boolean; + ariaLabel?: string; + testId?: string; + mt?: Spacing; + mb?: Spacing; + ml?: Spacing; + mr?: Spacing; + + options: RadioOption[] = []; + + onChange(event: GoabRadioGroupOnChangeDetail) { + /** do nothing **/ + } +} + +describe("GoABRadioGroup", () => { + let fixture: ComponentFixture; + let component: TestRadioGroupComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestRadioGroupComponent, GoabxRadioGroup, GoabxRadioItem], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestRadioGroupComponent); + component = fixture.componentInstance; + + // Assign values + component.name = "fruits"; + component.ariaLabel = "Fruit Radio Group"; + component.testId = "foo"; + component.value = "bananas"; + component.orientation = "horizontal"; + component.disabled = true; + component.error = true; + component.mt = "m"; + component.mb = "s"; + component.mr = "xl"; + component.ml = "2xl"; + + // Basic options + component.options = [ + { text: "Apples", value: "apples" }, + { text: "Oranges", value: "oranges", description: "Oranges are orange" }, + { text: "Bananas", value: "bananas" }, + ]; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const el = fixture.nativeElement.querySelector("goa-radio-group"); + expect(el).toBeTruthy(); + + expect(el?.getAttribute("arialabel")).toBe(component.ariaLabel); + expect(el?.getAttribute("testid")).toBe(component.testId); + expect(el?.getAttribute("error")).toBe(`${component.error}`); + expect(el?.getAttribute("mb")).toBe(component.mb); + expect(el?.getAttribute("ml")).toBe(component.ml); + expect(el?.getAttribute("mr")).toBe(component.mr); + expect(el?.getAttribute("mt")).toBe(component.mt); + expect(el?.getAttribute("orientation")).toBe(component.orientation); + expect(el?.getAttribute("value")).toBe(component.value); + + const radioItems = el?.querySelectorAll("goa-radio-item"); + expect(radioItems.length).toBe(component.options?.length); + component.options?.forEach((option, index) => { + expect(radioItems[index].getAttribute("checked")).toBe( + `${option.value === component.value}`, + ); + expect(radioItems[index].getAttribute("label")).toBe(option.text); + expect(radioItems[index].getAttribute("name")).toBe(component.name); + expect(radioItems[index].getAttribute("value")).toBe(option.value); + }); + }); + + it("should render description", () => { + component.options.forEach((option, index) => { + component.options[index].description = + `Description for ${component.options[index].text}`; + }); + component.options[0].isDescriptionSlot = true; + fixture.detectChanges(); + + const radioGroup = fixture.nativeElement.querySelector("goa-radio-group"); + expect(radioGroup).toBeTruthy(); + const radioItems = radioGroup?.querySelectorAll("goa-radio-item"); + expect(radioItems.length).toBe(component.options.length); + + // Slot description + expect(radioItems[0].querySelector("div[slot='description']")?.innerHTML).toContain( + `Description for ${component.options[0].text}`, + ); + + // attribute description + expect(radioItems[1].getAttribute("description")).toBe( + `Description for ${component.options[1].text}`, + ); + expect(radioItems[2].getAttribute("description")).toBe( + `Description for ${component.options[2].text}`, + ); + }); + + it("should dispatch onChange", () => { + const onChange = jest.spyOn(component, "onChange"); + const changeEvent = new Event("change"); + + const radioGroup = fixture.nativeElement.querySelector("goa-radio-group"); + fireEvent( + radioGroup, + new CustomEvent("_change", { + detail: { + name: component.name, + value: component.options[0].value, + event: changeEvent, + }, + }), + ); + + expect(onChange).toBeCalledWith( + expect.objectContaining({ + name: component.name, + value: component.options[0].value, + event: expect.any(Event), + }), + ); + }); + + describe("writeValue", () => { + it("should set value attribute when writeValue is called", () => { + const radioGroupComponent = fixture.debugElement.query( + By.css("goabx-radio-group"), + ).componentInstance; + const radioGroupElement = fixture.nativeElement.querySelector("goa-radio-group"); + + radioGroupComponent.writeValue("apples"); + expect(radioGroupElement.getAttribute("value")).toBe("apples"); + + radioGroupComponent.writeValue("oranges"); + expect(radioGroupElement.getAttribute("value")).toBe("oranges"); + }); + + it("should set value attribute to empty string when writeValue is called with null or empty", () => { + const radioGroupComponent = fixture.debugElement.query( + By.css("goabx-radio-group"), + ).componentInstance; + const radioGroupElement = fixture.nativeElement.querySelector("goa-radio-group"); + + // First set a value + radioGroupComponent.writeValue("bananas"); + expect(radioGroupElement.getAttribute("value")).toBe("bananas"); + + // Then clear it with null + radioGroupComponent.writeValue(null); + expect(radioGroupElement.getAttribute("value")).toBe(""); + + // Set again and clear with undefined + radioGroupComponent.writeValue("apples"); + radioGroupComponent.writeValue(undefined); + expect(radioGroupElement.getAttribute("value")).toBe(""); + + // Set again and clear with empty string + radioGroupComponent.writeValue("oranges"); + radioGroupComponent.writeValue(""); + expect(radioGroupElement.getAttribute("value")).toBe(""); + }); + + it("should update component value property", () => { + const radioGroupComponent = fixture.debugElement.query( + By.css("goabx-radio-group"), + ).componentInstance; + + radioGroupComponent.writeValue("apples"); + expect(radioGroupComponent.value).toBe("apples"); + + radioGroupComponent.writeValue(null); + expect(radioGroupComponent.value).toBe(null); + }); + }); +}); diff --git a/libs/angular-components/src/experimental/radio-group/radio-group.ts b/libs/angular-components/src/experimental/radio-group/radio-group.ts new file mode 100644 index 0000000000..ccd3e95181 --- /dev/null +++ b/libs/angular-components/src/experimental/radio-group/radio-group.ts @@ -0,0 +1,88 @@ +import { + GoabRadioGroupOnChangeDetail, + GoabRadioGroupOrientation, + GoabRadioGroupSize, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + forwardRef, + OnInit, + ChangeDetectorRef, + Renderer2, +} from "@angular/core"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CommonModule } from "@angular/common"; +import { GoabControlValueAccessor } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-radio-group", + template: ` + + + + `, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => GoabxRadioGroup), + }, + ], +}) +export class GoabxRadioGroup extends GoabControlValueAccessor implements OnInit { + isReady = false; + version = "2"; + @Input() name?: string; + @Input() orientation?: GoabRadioGroupOrientation; + @Input() ariaLabel?: string; + @Input() size?: GoabRadioGroupSize = "default"; + + constructor( + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); + } + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } + + @Output() onChange = new EventEmitter(); + + _onChange(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + this.markAsTouched(); + this.onChange.emit(detail); + + this.fcChange?.(detail.value); + } +} diff --git a/libs/angular-components/src/experimental/radio-item/radio-item.spec.ts b/libs/angular-components/src/experimental/radio-item/radio-item.spec.ts new file mode 100644 index 0000000000..3189d88ff6 --- /dev/null +++ b/libs/angular-components/src/experimental/radio-item/radio-item.spec.ts @@ -0,0 +1,49 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabxRadioItem } from "./radio-item"; + +@Component({ + standalone: true, + imports: [GoabxRadioItem], + template: ` + + + A reveal slot + + + `, +}) +class TestRadioItemWithRevealSlotComponent { } + +describe("Radio item with reveal slot", () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxRadioItem, TestRadioItemWithRevealSlotComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestRadioItemWithRevealSlotComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render with slot reveal", () => { + const radioItemElement = fixture.debugElement.nativeElement.querySelector("goa-radio-item"); + const slotReveal = radioItemElement.querySelector("[slot='reveal']"); + expect(slotReveal.textContent).toContain("A reveal slot"); + }); + + it("should pass the revealAriaLabel property to the web component", () => { + const radioItemElement = fixture.debugElement.nativeElement.querySelector("goa-radio-item"); + expect(radioItemElement.getAttribute("revealarialabel")).toBe("Screen reader announcement for radio reveal content"); + }); +}); diff --git a/libs/angular-components/src/experimental/radio-item/radio-item.ts b/libs/angular-components/src/experimental/radio-item/radio-item.ts new file mode 100644 index 0000000000..b288f3ed6a --- /dev/null +++ b/libs/angular-components/src/experimental/radio-item/radio-item.ts @@ -0,0 +1,88 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + TemplateRef, + booleanAttribute, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { NgTemplateOutlet, CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-radio-item", + template: ` + + +
+ +
+
+ +
+
+ `, + imports: [NgTemplateOutlet, CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxRadioItem extends GoabBaseComponent { + @Input() value?: string; + @Input() label?: string; + @Input() name?: string; + @Input() description!: string | TemplateRef; + @Input() reveal?: TemplateRef; + @Input() ariaLabel?: string; + @Input() revealAriaLabel?: string; + @Input({ transform: booleanAttribute }) disabled?: boolean; + @Input({ transform: booleanAttribute }) checked?: boolean; + @Input({ transform: booleanAttribute }) error?: boolean; + @Input() maxWidth?: string; + @Input({ transform: booleanAttribute }) compact?: boolean; + + isReady = false; + version = "2"; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + getDescriptionAsString(): string { + return !this.description || this.description instanceof TemplateRef + ? "" + : this.description; + } + + getDescriptionAsTemplate(): TemplateRef | null { + if (!this.description) return null; + return this.description instanceof TemplateRef ? this.description : null; + } +} diff --git a/libs/angular-components/src/experimental/side-menu-group/side-menu-group.spec.ts b/libs/angular-components/src/experimental/side-menu-group/side-menu-group.spec.ts new file mode 100644 index 0000000000..a24c187787 --- /dev/null +++ b/libs/angular-components/src/experimental/side-menu-group/side-menu-group.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxSideMenuGroup } from "./side-menu-group"; +import { Component } from "@angular/core"; + +@Component({ + standalone: true, + imports: [GoabxSideMenuGroup], + template: ` + + Link + + `, +}) +class TestSideMenuGroupComponent { + heading = "some header"; + testId = "foo"; +} + +describe("GoABSideMenuGroup", () => { + let fixture: ComponentFixture; + let component: TestSideMenuGroupComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxSideMenuGroup, TestSideMenuGroupComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestSideMenuGroupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const el = fixture.nativeElement.querySelector("goa-side-menu-group"); + expect(el?.getAttribute("heading")).toBe(component.heading); + expect(el?.getAttribute("testid")).toBe(component.testId); + expect(el?.querySelector("a")?.textContent).toContain("Link"); + }); +}); diff --git a/libs/angular-components/src/experimental/side-menu-group/side-menu-group.ts b/libs/angular-components/src/experimental/side-menu-group/side-menu-group.ts new file mode 100644 index 0000000000..467d891ae8 --- /dev/null +++ b/libs/angular-components/src/experimental/side-menu-group/side-menu-group.ts @@ -0,0 +1,43 @@ +import { CUSTOM_ELEMENTS_SCHEMA, Component, Input, OnInit, ChangeDetectorRef } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabIconType } from "@abgov/ui-components-common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-side-menu-group", + template: ` + + + + `, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxSideMenuGroup extends GoabBaseComponent implements OnInit { + isReady = false; + version = "2"; + @Input({ required: true }) heading!: string; + @Input() icon?: GoabIconType; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } +} diff --git a/libs/angular-components/src/experimental/side-menu-heading/side-menu-heading.spec.ts b/libs/angular-components/src/experimental/side-menu-heading/side-menu-heading.spec.ts new file mode 100644 index 0000000000..080ccdc96e --- /dev/null +++ b/libs/angular-components/src/experimental/side-menu-heading/side-menu-heading.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxSideMenuHeading } from "./side-menu-heading"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabxBadge } from "../badge/badge"; + +@Component({ + standalone: true, + imports: [GoabxSideMenuHeading, GoabxBadge], + template: ` + Heading + + + `, +}) +class TestSideMenuHeadingComponent { + /** do nothing **/ +} + +describe("GoABSideMenuHeading", () => { + let fixture: ComponentFixture; + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxSideMenuHeading, GoabxBadge, TestSideMenuHeadingComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(TestSideMenuHeadingComponent); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const el = fixture.nativeElement.querySelector("goa-side-menu-heading"); + expect(el?.getAttribute("icon")).toBe("home"); + expect(el?.getAttribute("testid")).toBe("foo"); + expect(el?.textContent).toContain("Heading"); + expect(el?.querySelector("[slot='meta']")?.innerHTML).toContain("details"); + }) +}) diff --git a/libs/angular-components/src/experimental/side-menu-heading/side-menu-heading.ts b/libs/angular-components/src/experimental/side-menu-heading/side-menu-heading.ts new file mode 100644 index 0000000000..b7120d1303 --- /dev/null +++ b/libs/angular-components/src/experimental/side-menu-heading/side-menu-heading.ts @@ -0,0 +1,39 @@ +import { GoabIconType } from "@abgov/ui-components-common"; +import { CUSTOM_ELEMENTS_SCHEMA, Component, Input, TemplateRef, OnInit, ChangeDetectorRef } from "@angular/core"; +import { NgTemplateOutlet, CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-side-menu-heading", + imports: [NgTemplateOutlet, CommonModule], + template: ` + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxSideMenuHeading implements OnInit { + isReady = false; + version = "2"; + @Input() icon!: GoabIconType; + @Input() testId?: string; + @Input() meta!: TemplateRef; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } +} diff --git a/libs/angular-components/src/experimental/side-menu/side-menu.spec.ts b/libs/angular-components/src/experimental/side-menu/side-menu.spec.ts new file mode 100644 index 0000000000..db3b8e61e5 --- /dev/null +++ b/libs/angular-components/src/experimental/side-menu/side-menu.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxSideMenu } from "./side-menu"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; + +@Component({ + standalone: true, + imports: [GoabxSideMenu], + template: ` + + Link + + `, +}) +class TestSideMenuComponent { + /** do nothing **/ +} + +describe("GoABSideMenu", () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxSideMenu, TestSideMenuComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestSideMenuComponent); + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const el = fixture.nativeElement.querySelector("goa-side-menu"); + expect(el?.getAttribute("testid")).toBe("foo"); + expect(el?.querySelector("a")?.textContent).toBe("Link"); + }); +}); diff --git a/libs/angular-components/src/experimental/side-menu/side-menu.ts b/libs/angular-components/src/experimental/side-menu/side-menu.ts new file mode 100644 index 0000000000..350f4f7908 --- /dev/null +++ b/libs/angular-components/src/experimental/side-menu/side-menu.ts @@ -0,0 +1,32 @@ +import { CUSTOM_ELEMENTS_SCHEMA, Component, Input, OnInit, ChangeDetectorRef } from "@angular/core"; +import { CommonModule } from "@angular/common"; + +@Component({ + standalone: true, + selector: "goabx-side-menu", + template: ` + + + + `, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GoabxSideMenu implements OnInit { + isReady = false; + version = "2"; + @Input() testId?: string; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } +} diff --git a/libs/angular-components/src/experimental/table/table.spec.ts b/libs/angular-components/src/experimental/table/table.spec.ts new file mode 100644 index 0000000000..11ce0650b3 --- /dev/null +++ b/libs/angular-components/src/experimental/table/table.spec.ts @@ -0,0 +1,107 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxTable } from "./table"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { + GoabTableOnSortDetail, + GoabTableVariant, + Spacing, +} from "@abgov/ui-components-common"; +import { fireEvent } from "@testing-library/dom"; + +@Component({ + standalone: true, + imports: [GoabxTable], + template: ` + + + + Column 1 + + + + + Row 1 + + + + `, +}) +class TestTableComponent { + width?: string; + variant?: GoabTableVariant; + testId?: string; + mt?: Spacing; + mb?: Spacing; + mr?: Spacing; + ml?: Spacing; + + onSort(event: GoabTableOnSortDetail) { + /** do nothing **/ + } +} + +describe("GoabxTable", () => { + let fixture: ComponentFixture; + let component: TestTableComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxTable, TestTableComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestTableComponent); + component = fixture.componentInstance; + + component.width = "200px"; + component.variant = "relaxed"; + component.testId = "foo"; + component.mt = "s" as Spacing; + component.mb = "xl" as Spacing; + component.ml = "m" as Spacing; + component.mr = "2xl" as Spacing; + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const el = fixture.nativeElement.querySelector("goa-table"); + expect(el?.getAttribute("width")).toBe(component.width); + expect(el?.getAttribute("variant")).toBe(component.variant); + expect(el?.getAttribute("testid")).toBe(component.testId); + expect(el?.getAttribute("mt")).toBe(component.mt); + expect(el?.getAttribute("mb")).toBe(component.mb); + expect(el?.getAttribute("mr")).toBe(component.mr); + expect(el?.getAttribute("ml")).toBe(component.ml); + // Check table + const table = el?.querySelector("table"); + expect(table).toBeTruthy(); + expect(table.getAttribute("style")).toContain("width: 100%"); + expect(table.querySelector("thead")?.textContent).toContain("Column 1"); + expect(table.querySelector("tbody")?.textContent).toContain("Row 1"); + }); + + it("should dispatch _sort", () => { + const onSort = jest.spyOn(component, "onSort"); + const el = fixture.nativeElement.querySelector("goa-table"); + fireEvent( + el, + new CustomEvent("_sort", { + detail: { sortBy: "column1", sortDir: 1 }, + }), + ); + + expect(onSort).toHaveBeenCalledWith({ sortBy: "column1", sortDir: 1 }); + }); +}); diff --git a/libs/angular-components/src/experimental/table/table.ts b/libs/angular-components/src/experimental/table/table.ts new file mode 100644 index 0000000000..e57e05cb10 --- /dev/null +++ b/libs/angular-components/src/experimental/table/table.ts @@ -0,0 +1,64 @@ +import { GoabTableOnSortDetail, GoabTableVariant } from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + OnInit, + ChangeDetectorRef, + booleanAttribute, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabBaseComponent } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-table", + template: ` + + + +
+
+ `, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxTable extends GoabBaseComponent implements OnInit { + isReady = false; + version = "2"; + @Input() width?: string; + @Input() variant?: GoabTableVariant; + @Input({ transform: booleanAttribute }) striped?: boolean; + + constructor(private cdr: ChangeDetectorRef) { + super(); + } + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } + + @Output() onSort = new EventEmitter(); + + _onSort(e: Event) { + const detail = (e as CustomEvent).detail; + this.onSort.emit(detail); + } +} diff --git a/libs/angular-components/src/experimental/tabs/tabs.spec.ts b/libs/angular-components/src/experimental/tabs/tabs.spec.ts new file mode 100644 index 0000000000..2351c05491 --- /dev/null +++ b/libs/angular-components/src/experimental/tabs/tabs.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxTabs } from "./tabs"; +import { GoabTab } from "../../lib/components/tab/tab"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabTabsOnChangeDetail } from "@abgov/ui-components-common"; +import { fireEvent } from "@testing-library/dom"; + +@Component({ + standalone: true, + imports: [GoabxTabs, GoabTab], + template: ` + + Tab content + + `, +}) +class TestTabsComponent { + /** do nothing **/ + onChange(event: GoabTabsOnChangeDetail) { + /** do nothing **/ + } +} + +describe("GoABTabs", () => { + let fixture: ComponentFixture; + let component: TestTabsComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [GoabxTabs, TestTabsComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestTabsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const el = fixture.nativeElement.querySelector("goa-tabs"); + expect(el?.getAttribute("initialtab")).toBe("1"); + expect(el?.getAttribute("testid")).toBe("foo"); + expect(el?.innerHTML).toContain("Profile"); + expect(el?.textContent).toContain("Tab content"); + }); + + it("should dispatch _onChange", () => { + const onChange = jest.spyOn(component, "onChange"); + + const el = fixture.nativeElement.querySelector("goa-tabs"); + fireEvent( + el, + new CustomEvent("_change", { + detail: { tab: 2 }, + }), + ); + + expect(onChange).toHaveBeenCalledWith({ tab: 2 }); + }); +}); diff --git a/libs/angular-components/src/experimental/tabs/tabs.ts b/libs/angular-components/src/experimental/tabs/tabs.ts new file mode 100644 index 0000000000..3e491c83a3 --- /dev/null +++ b/libs/angular-components/src/experimental/tabs/tabs.ts @@ -0,0 +1,54 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + Input, + Output, + EventEmitter, + numberAttribute, + OnInit, + ChangeDetectorRef, +} from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { GoabTabsOnChangeDetail, GoabTabsVariant } from "@abgov/ui-components-common"; + +@Component({ + standalone: true, + selector: "goabx-tabs", + template: ` + + + + `, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class GoabxTabs implements OnInit { + isReady = false; + version = "2"; + @Input({ transform: numberAttribute }) initialTab?: number; + @Input() testId?: string; + @Input() variant?: GoabTabsVariant; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void { + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }); + } + + @Output() onChange = new EventEmitter(); + + _onChange(e: Event) { + const detail = (e as CustomEvent).detail; + this.onChange.emit(detail); + } +} diff --git a/libs/angular-components/src/experimental/textarea/textarea.spec.ts b/libs/angular-components/src/experimental/textarea/textarea.spec.ts new file mode 100644 index 0000000000..f79f3c5f95 --- /dev/null +++ b/libs/angular-components/src/experimental/textarea/textarea.spec.ts @@ -0,0 +1,182 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { GoabxTextArea } from "./textarea"; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { GoabTextAreaCountBy, Spacing } from "@abgov/ui-components-common"; +import { fireEvent } from "@testing-library/dom"; +import { By } from "@angular/platform-browser"; + +@Component({ + standalone: true, + imports: [GoabxTextArea], + template: ` + + `, +}) +class TestTextareaComponent { + name = "textarea-name"; + value?: string; + id?: string; + placeholder?: string; + rows?: number; + error?: boolean; + disabled?: boolean; + width?: string; + testId?: string; + ariaLabel?: string; + countBy?: GoabTextAreaCountBy; + maxCount?: number; + mt?: Spacing; + mb?: Spacing; + mr?: Spacing; + ml?: Spacing; + + onChange() { + /** do nothing **/ + } + + onBlur() { + /** do nothing **/ + } +} + +describe("GoABTextArea", () => { + let fixture: ComponentFixture; + let component: TestTextareaComponent; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [TestTextareaComponent, GoabxTextArea], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + fixture = TestBed.createComponent(TestTextareaComponent); + component = fixture.componentInstance; + + component.testId = "textarea-testid"; + component.value = "textarea-value"; + component.rows = 10; + component.placeholder = "textarea-placeholder"; + component.disabled = true; + component.countBy = "word"; + component.maxCount = 50; + component.mt = "s" as Spacing; + component.mr = "m" as Spacing; + component.mb = "l" as Spacing; + component.ml = "xl" as Spacing; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it("should render", () => { + const el = fixture.nativeElement.querySelector("goa-textarea"); + expect(el?.getAttribute("name")).toBe(component.name); + expect(el?.getAttribute("value")).toBe(component.value); + expect(el?.getAttribute("rows")).toBe(`${component.rows}`); + expect(el?.getAttribute("placeholder")).toBe(component.placeholder); + expect(el?.getAttribute("countby")).toBe(component.countBy); + expect(el?.getAttribute("maxcount")).toBe(`${component.maxCount}`); + expect(el?.getAttribute("maxwidth")).toBe("480px"); + expect(el?.getAttribute("mt")).toBe(component.mt); + expect(el?.getAttribute("mr")).toBe(component.mr); + expect(el?.getAttribute("mb")).toBe(component.mb); + expect(el?.getAttribute("ml")).toBe(component.ml); + expect(el?.getAttribute("autocomplete")).toBe("off"); + }); + + it("should dispatch onChange", () => { + const onChange = jest.spyOn(component, "onChange"); + + const el = fixture.nativeElement.querySelector("goa-textarea"); + fireEvent( + el, + new CustomEvent("_change", { + detail: { name: "textarea-name", value: "test" }, + }), + ); + + expect(onChange).toBeCalledTimes(1); + }); + + it("should dispatch onBlur", () => { + const onBlur = jest.spyOn(component, "onBlur"); + + const el = fixture.nativeElement.querySelector("goa-textarea"); + fireEvent( + el, + new CustomEvent("_blur", { + detail: { name: "textarea-name", value: "test value" }, + }), + ); + + expect(onBlur).toBeCalledTimes(1); + }); + + describe("writeValue", () => { + it("should set value attribute when writeValue is called", () => { + const textareaComponent = fixture.debugElement.query( + By.css("goabx-textarea"), + ).componentInstance; + const textareaElement = fixture.nativeElement.querySelector("goa-textarea"); + + textareaComponent.writeValue("new content"); + expect(textareaElement.getAttribute("value")).toBe("new content"); + + textareaComponent.writeValue("updated content"); + expect(textareaElement.getAttribute("value")).toBe("updated content"); + }); + + it("should set value attribute to empty string when writeValue is called with null or empty", () => { + const textareaComponent = fixture.debugElement.query( + By.css("goabx-textarea"), + ).componentInstance; + const textareaElement = fixture.nativeElement.querySelector("goa-textarea"); + + // First set a value + textareaComponent.writeValue("some content"); + expect(textareaElement.getAttribute("value")).toBe("some content"); + + // Then clear it with null + textareaComponent.writeValue(null); + expect(textareaElement.getAttribute("value")).toBe(""); + + // Set again and clear with undefined + textareaComponent.writeValue("test content"); + textareaComponent.writeValue(undefined); + expect(textareaElement.getAttribute("value")).toBe(""); + + // Set again and clear with empty string + textareaComponent.writeValue("more content"); + textareaComponent.writeValue(""); + expect(textareaElement.getAttribute("value")).toBe(""); + }); + + it("should update component value property", () => { + const textareaComponent = fixture.debugElement.query( + By.css("goabx-textarea"), + ).componentInstance; + + textareaComponent.writeValue("updated value"); + expect(textareaComponent.value).toBe("updated value"); + + textareaComponent.writeValue(null); + expect(textareaComponent.value).toBe(null); + }); + }); +}); diff --git a/libs/angular-components/src/experimental/textarea/textarea.ts b/libs/angular-components/src/experimental/textarea/textarea.ts new file mode 100644 index 0000000000..e274e94cbf --- /dev/null +++ b/libs/angular-components/src/experimental/textarea/textarea.ts @@ -0,0 +1,122 @@ +import { + GoabTextAreaCountBy, + GoabTextAreaOnChangeDetail, + GoabTextAreaOnKeyPressDetail, + GoabTextAreaOnBlurDetail, + GoabTextAreaSize, +} from "@abgov/ui-components-common"; +import { + CUSTOM_ELEMENTS_SCHEMA, + Component, + EventEmitter, + Input, + Output, + booleanAttribute, + forwardRef, + numberAttribute, + OnInit, + ChangeDetectorRef, + Renderer2, +} from "@angular/core"; +import { NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CommonModule } from "@angular/common"; +import { GoabControlValueAccessor } from "../base.component"; + +@Component({ + standalone: true, + selector: "goabx-textarea", + imports: [CommonModule], + template: ` + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => GoabxTextArea), + }, + ], +}) +export class GoabxTextArea extends GoabControlValueAccessor implements OnInit { + @Input() name?: string; + @Input() placeholder?: string; + @Input({ transform: numberAttribute }) rows?: number; + @Input({ transform: booleanAttribute }) readOnly?: boolean; + @Input() width?: string; + @Input() ariaLabel?: string; + @Input() countBy?: GoabTextAreaCountBy = ""; + @Input() maxCount?: number = -1; + @Input() maxWidth?: string; + @Input() autoComplete?: string = "on"; + @Input() size?: GoabTextAreaSize = "default"; + + @Output() onChange = new EventEmitter(); + @Output() onKeyPress = new EventEmitter(); + @Output() onBlur = new EventEmitter(); + + isReady = false; + version = "2"; + + constructor( + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); + } + + ngOnInit(): void { + // For Angular 20, we need to delay rendering the web component + // to ensure all attributes are properly bound before the component initializes + setTimeout(() => { + this.isReady = true; + this.cdr.detectChanges(); + }, 0); + } + + _onChange(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + this.onChange.emit(detail); + this.markAsTouched(); + this.fcChange?.(detail.value); + } + + _onKeyPress(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + this.markAsTouched(); + this.onKeyPress.emit(detail); + } + + _onBlur(e: Event) { + const detail = { ...(e as CustomEvent).detail, event: e }; + this.markAsTouched(); + this.onBlur.emit(detail); + } +} diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 718e0f889d..562d2032b8 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -1,4 +1,5 @@ export * from "./lib/common"; +export * from "./lib/experimental/common"; export * from "./lib/validators"; export * from "./lib/public-form-controller"; export * from "./lib/temporary-notification-controller/temporary-notification-controller"; diff --git a/libs/common/src/lib/experimental/common.ts b/libs/common/src/lib/experimental/common.ts new file mode 100644 index 0000000000..1aabc61019 --- /dev/null +++ b/libs/common/src/lib/experimental/common.ts @@ -0,0 +1,42 @@ +export type GoabBadgeSize = "medium" | "large"; + +export type GoabBadgeEmphasis = "subtle" | "strong"; + +export type GoabCalloutEmphasis = "high" | "medium" | "low"; + +export type GoabCheckboxSize = "default" | "compact"; + +export type GoabDropdownSize = "default" | "compact"; + +export type GoabFormItemType = + | "" + | "text-input" + | "textarea" + | "checkbox-list" + | "radio-group"; + +export type GoabLinkColor = "interactive" | "dark" | "light"; + +export type GoabLinkSize = "xsmall" | "small" | "medium" | "large"; + +export type GoabNotificationEmphasis = "high" | "low"; + +export type GoabRadioGroupSize = "default" | "compact"; + +export type GoabInputSize = "default" | "compact"; + +export type GoabTextAreaSize = "default" | "compact"; + +export type GoabxBadgeType = + | "information" + | "success" + | "important" + | "emergency" + | "archived" + | "sky" + | "prairie" + | "lilac" + | "pasture" + | "sunset" + | "dawn" + | "default"; diff --git a/libs/react-components/src/experimental/badge/badge.spec.tsx b/libs/react-components/src/experimental/badge/badge.spec.tsx new file mode 100644 index 0000000000..4422eb7824 --- /dev/null +++ b/libs/react-components/src/experimental/badge/badge.spec.tsx @@ -0,0 +1,153 @@ +import { configure, render } from "@testing-library/react"; +import { GoabxBadge } from "./badge"; + +configure({ testIdAttribute: "testId" }); + +describe("GoabxBadge", () => { + it("should render with default behavior (no icon)", () => { + const { container } = render(); + + const el = container.querySelector("goa-badge"); + expect(el?.getAttribute("icon")).toBe("false"); + }); + + it("should render the properties", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + + expect(el?.getAttribute("type")).toBe("information"); + expect(el?.getAttribute("content")).toBe("Text Content"); + expect(el?.getAttribute("icon")).toBe("true"); + expect(el?.getAttribute("size")).toBe("large"); + expect(el?.getAttribute("emphasis")).toBe("subtle"); + expect(el?.getAttribute("mt")).toBe("s"); + expect(el?.getAttribute("mr")).toBe("m"); + expect(el?.getAttribute("mb")).toBe("l"); + expect(el?.getAttribute("ml")).toBe("xl"); + expect(el?.getAttribute("arialabel")).toBe("text"); + }); + + it("should pass data-grid attributes", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); + + it("should render custom icon type when icontype is provided", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + + expect(el?.getAttribute("icontype")).toBe("home"); + expect(el?.getAttribute("icon")).toBe("true"); + }); + + it("should not render icontype when not provided", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + + expect(el?.getAttribute("icontype")).toBeNull(); + expect(el?.getAttribute("icon")).toBe("true"); + }); + + it("should pass icon=false correctly to web component", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + + expect(el?.getAttribute("icon")).toBe("false"); + expect(el?.getAttribute("icontype")).toBe("star"); + }); + + it("should pass icon='true' when icon={true} explicitly", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + + expect(el?.getAttribute("icon")).toBe("true"); + expect(el?.getAttribute("icontype")).toBeNull(); + }); + + it("should pass icon='false' when icon={false} without iconType", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + + expect(el?.getAttribute("icon")).toBe("false"); + expect(el?.getAttribute("icontype")).toBeNull(); + }); + + it("should pass icon='false' when icon={undefined}", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + + expect(el?.getAttribute("icon")).toBe("false"); + }); + + it("should pass icon='true' when iconType and icon={true} both provided", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-badge"); + + expect(el?.getAttribute("icon")).toBe("true"); + expect(el?.getAttribute("icontype")).toBe("star"); + }); +}); diff --git a/libs/react-components/src/experimental/badge/badge.tsx b/libs/react-components/src/experimental/badge/badge.tsx new file mode 100644 index 0000000000..b5fb71c3f2 --- /dev/null +++ b/libs/react-components/src/experimental/badge/badge.tsx @@ -0,0 +1,85 @@ +import { + DataAttributes, + GoabBadgeEmphasis, + GoabBadgeSize, + GoabBadgeType, + GoabxBadgeType, + GoabIconType, + Margins, +} from "@abgov/ui-components-common"; +import type { JSX } from "react"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps extends Margins { + type: GoabBadgeType; + icon?: string; + content?: string; + arialabel?: string; + testid?: string; + icontype?: GoabIconType; + size?: GoabBadgeSize; + emphasis?: GoabBadgeEmphasis; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-badge": WCProps & React.HTMLAttributes; + } + } +} + +export interface GoabxBadgeProps extends Margins, DataAttributes { + type: GoabxBadgeType; + icon?: boolean; + content?: string; + testId?: string; + ariaLabel?: string; + iconType?: GoabIconType; + size?: GoabBadgeSize; + emphasis?: GoabBadgeEmphasis; + version?: string; +} + +/** + * Determines the icon display logic for the badge component. + * Priority order: + * 1. icon={true} - always show icon, starting with default + * 2. icon={false} - always hide icon (overrides iconType) + * 3. iconType provided - show custom icon + * 4. default/no icon or iconType set - hide icon + */ +function getIconValue(icon?: boolean, iconType?: GoabIconType): "true" | "false" { + // Explicit icon prop takes precedence + if (icon !== undefined) { + return icon ? "true" : "false"; + } + + // Show custom icon if iconType is provided + return iconType ? "true" : "false"; +} + +export function GoabxBadge({ + icon, + iconType, + size = "medium", + emphasis = "strong", + version = "2", + ...rest +}: GoabxBadgeProps): JSX.Element { + const _props = transformProps({ size, emphasis, ...rest }, lowercase); + + return ( + + ); +} diff --git a/libs/react-components/src/experimental/button/button.spec.tsx b/libs/react-components/src/experimental/button/button.spec.tsx new file mode 100644 index 0000000000..0a1d76eb12 --- /dev/null +++ b/libs/react-components/src/experimental/button/button.spec.tsx @@ -0,0 +1,160 @@ +import { render } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/dom"; +import GoabxButton from "./button"; +import { describe, it, expect, vi } from "vitest"; +import { GoabButtonSize, GoabButtonType } from "@abgov/ui-components-common"; + +describe("GoabxButton", () => { + const buttonText = "Test Title"; + + const noop = () => { + /* do nothing */ + }; + + it("should render", () => { + const { container } = render(); + + const el = container.querySelector("goa-button"); + expect(el?.getAttribute("disabled")).toBeNull(); + }); + + it("should render the properties", () => { + const { container } = render( + , + ); + const el = container.querySelector("goa-button"); + + expect(el?.getAttribute("disabled")).toBe("true"); + expect(el?.getAttribute("type")).toBe("primary"); + expect(el?.getAttribute("size")).toBe("compact"); + expect(el?.getAttribute("variant")).toBe("destructive"); + expect(el?.getAttribute("leadingicon")).toBe("car"); + expect(el?.getAttribute("trailingicon")).toBe("bag"); + + expect(el?.getAttribute("mt")).toBe("s"); + expect(el?.getAttribute("mr")).toBe("m"); + expect(el?.getAttribute("mb")).toBe("l"); + expect(el?.getAttribute("ml")).toBe("xl"); + }); + + it("should render content", () => { + const { baseElement } = render( + { + /* do nothing */ + }} + > + {buttonText} + , + ); + + expect(baseElement).toBeTruthy(); + expect(screen.getByText(buttonText)); + }); + + describe("size", () => { + (["compact", "normal"] as const).forEach((size: GoabButtonSize) => { + it(`should render ${size} size`, async () => { + const { container } = render( + + Button + , + ); + + const button = container.querySelector("goa-button"); + expect(button).toBeTruthy(); + expect(button?.getAttribute("size")).toEqual(size); + }); + }); + }); + + describe("type", () => { + (["primary", "submit", "secondary", "tertiary"] as const).forEach( + (type: GoabButtonType) => { + it(`should render ${type} type`, async () => { + const { container } = render( + + Button + , + ); + const button = container.querySelector("goa-button"); + + expect(button).toBeTruthy(); + expect(button?.getAttribute("type")).toEqual(type); + }); + }, + ); + }); + + it("responds to events", async () => { + const onClick = vi.fn(); + const { container } = render(Button); + const button = container.querySelector("goa-button"); + expect(button).toBeTruthy(); + button && fireEvent(button, new CustomEvent("_click")); + expect(onClick).toBeCalled(); + }); + + it("should pass data-grid attributes", () => { + const { container } = render( + + Button Text + , + ); + const el = container.querySelector("goa-button"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); + +describe("GoabxButton disabled attribute", () => { + it("should set disabled attribute correctly when disabled=true", () => { + const { container } = render( + Disabled Button + ); + const el = container.querySelector("goa-button"); + + expect(el?.getAttribute("disabled")).toBe("true"); + }); + + it("should not include disabled attribute when disabled=false", () => { + const { container } = render( + Enabled Button + ); + const el = container.querySelector("goa-button"); + + // disabled attribute should not be present + expect(el?.hasAttribute("disabled")).toBe(false); + }); + + it("should handle toggle between disabled states", () => { + // First render with disabled=true + const { container, rerender } = render( + Toggle Button + ); + let el = container.querySelector("goa-button"); + expect(el?.getAttribute("disabled")).toBe("true"); + + // Rerender with disabled=false + rerender(Toggle Button); + el = container.querySelector("goa-button"); + expect(el?.hasAttribute("disabled")).toBe(false); + + // Rerender with disabled=true again + rerender(Toggle Button); + el = container.querySelector("goa-button"); + expect(el?.getAttribute("disabled")).toBe("true"); + }); +}); diff --git a/libs/react-components/src/experimental/button/button.tsx b/libs/react-components/src/experimental/button/button.tsx new file mode 100644 index 0000000000..85e608cd20 --- /dev/null +++ b/libs/react-components/src/experimental/button/button.tsx @@ -0,0 +1,101 @@ +import { ReactNode, useEffect, useRef, type JSX } from "react"; +import { + GoabButtonSize, + GoabButtonType, + GoabButtonVariant, + GoabIconType, + Margins, + DataAttributes, +} from "@abgov/ui-components-common"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps extends Margins { + type?: GoabButtonType; + size?: GoabButtonSize; + variant?: GoabButtonVariant; + disabled?: string; + leadingicon?: string; + trailingicon?: string; + width?: string; + testid?: string; + action?: string; + actionArgs?: string; + actionArg?: string; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-button": WCProps & + React.HTMLAttributes & { + ref: React.RefObject; + }; + } + } +} + +export interface GoabxButtonProps extends Margins, DataAttributes { + type?: GoabButtonType; + size?: GoabButtonSize; + variant?: GoabButtonVariant; + disabled?: boolean; + leadingIcon?: GoabIconType; + trailingIcon?: GoabIconType; + width?: string; + onClick?: () => void; + testId?: string; + action?: string; + actionArgs?: Record; + actionArg?: string; + version?: string; + children?: ReactNode; +} + +export function GoabxButton({ + disabled, + onClick, + actionArgs, + actionArg, + children, + version = "2", + ...rest +}: GoabxButtonProps): JSX.Element { + const el = useRef(null); + + const _props = transformProps(rest, lowercase); + + useEffect(() => { + if (!el.current) { + return; + } + if (!onClick) { + return; + } + const current = el.current; + const listener = () => { + onClick?.(); + }; + + current.addEventListener("_click", listener); + return () => { + current.removeEventListener("_click", listener); + }; + }, [el, onClick]); + + return ( + + {children} + + ); +} + +export default GoabxButton; diff --git a/libs/react-components/src/experimental/calendar/calendar.spec.tsx b/libs/react-components/src/experimental/calendar/calendar.spec.tsx new file mode 100644 index 0000000000..2b3b02b087 --- /dev/null +++ b/libs/react-components/src/experimental/calendar/calendar.spec.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render } from "@testing-library/react"; +import { addMonths } from "date-fns"; +import { describe, it, expect, vi } from "vitest"; + +import Calendar from "./calendar"; + +const noop = () => { /* do nothing */ }; + +describe("Calendar", () => { + it("should render successfully", () => { + const { baseElement, container } = render(); + expect(baseElement).toBeTruthy(); + + const calendar = container.querySelector("goa-calendar"); + expect(calendar).toBeTruthy(); + }); + + it("handle the event", () => { + const onChange = vi.fn(); + const name = "birthdate"; + const { container } = render(); + const calendar = container.querySelector("goa-calendar"); + + const detail = { type: "date", value: new Date(), name }; + calendar && fireEvent(calendar, new CustomEvent("_change", { detail })); + expect(onChange).toBeCalled(); + }); + + it("should set the props correctly", () => { + const value = "2025-02-03"; + const min = "2024-01-01" + const max = "2025-01-01" + + const { baseElement } = render( + + ); + const el = baseElement.querySelector("goa-calendar"); + expect(baseElement).toBeTruthy(); + expect(el?.getAttribute("value")).toBe(value); + expect(el?.getAttribute("min")).toBe(min); + expect(el?.getAttribute("max")).toBe(max); + expect(el?.getAttribute("testid")).toBe("foo"); + }); + + it("should pass data-grid attributes", () => { + const { baseElement } = render( + + ); + const el = baseElement.querySelector("goa-calendar"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/calendar/calendar.tsx b/libs/react-components/src/experimental/calendar/calendar.tsx new file mode 100644 index 0000000000..b40c446346 --- /dev/null +++ b/libs/react-components/src/experimental/calendar/calendar.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, type JSX } from "react"; +import { + DataAttributes, + GoabCalendarOnChangeDetail, + Margins, +} from "@abgov/ui-components-common"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps extends Margins { + name?: string; + value?: string; + min?: string; + max?: string; + testid?: string; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-calendar": WCProps & + React.HTMLAttributes & { + ref: React.RefObject; + }; + } + } +} +export interface GoabxCalendarProps extends Margins, DataAttributes { + name?: string; + value?: string; + min?: string; + max?: string; + testId?: string; + version?: string; + onChange: (details: GoabCalendarOnChangeDetail) => void; +} + +export function GoabxCalendar({ + min, + max, + onChange, + name, + version = "2", + ...rest +}: GoabxCalendarProps): JSX.Element { + const ref = useRef(null); + + const _props = transformProps(rest, lowercase); + + useEffect(() => { + if (!ref.current) { + return; + } + const current = ref.current; + const listener = (e: Event) => { + onChange({ + name: name || "", + value: (e as CustomEvent).detail.value, + }); + }; + current.addEventListener("_change", listener); + + return () => { + current.removeEventListener("_change", listener); + }; + }, []); + + return ( + + ); +} + +export default GoabxCalendar; diff --git a/libs/react-components/src/experimental/callout/callout.spec.tsx b/libs/react-components/src/experimental/callout/callout.spec.tsx new file mode 100644 index 0000000000..0439428ae1 --- /dev/null +++ b/libs/react-components/src/experimental/callout/callout.spec.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import GoabxCallout from "./callout"; + +describe("GoabxCallout", () => { + test("Callout shall render", async () => { + const result = render( + + Information to the user goes in the content. Information can include markup as + desired. + , + ); + + const el = result.container.querySelector("goa-callout"); + expect(el?.getAttribute("heading")).toContain("Callout Title"); + expect(el?.getAttribute("type")).toContain("information"); + expect(el?.getAttribute("size")).toContain("medium"); + expect(el?.getAttribute("emphasis")).toContain("high"); + expect(el?.getAttribute("maxwidth")).toBe("480px"); + expect(el?.getAttribute("mt")).toBe("s"); + expect(el?.getAttribute("mr")).toBe("m"); + expect(el?.getAttribute("mb")).toBe("l"); + expect(el?.getAttribute("ml")).toBe("xl"); + expect(el?.getAttribute("arialive")).toBe("polite"); + expect(el?.getAttribute("testid")).toBe("test-callout"); + expect(el?.textContent).toContain("Information to the user goes"); + }); + + test("Callout shall render with different ariaLive values", async () => { + const testCases = [ + { ariaLive: "assertive", expected: "assertive" }, + { ariaLive: "polite", expected: "polite" }, + { ariaLive: "off", expected: "off" }, + { ariaLive: undefined, expected: "off" }, + { ariaLive: "", expected: "" }, + ]; + + testCases.forEach(({ ariaLive, expected }) => { + const result = render( + + Test content + , + ); + + const el = result.container.querySelector("goa-callout"); + expect(el?.getAttribute("arialive")).toBe(expected); + }); + }); + + test("should pass data-grid attributes", () => { + const result = render( + + Test content + , + ); + const el = result.container.querySelector("goa-callout"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/callout/callout.tsx b/libs/react-components/src/experimental/callout/callout.tsx new file mode 100644 index 0000000000..4b6d528563 --- /dev/null +++ b/libs/react-components/src/experimental/callout/callout.tsx @@ -0,0 +1,67 @@ +import { + GoabCalloutAriaLive, + GoabCalloutEmphasis, + GoabCalloutSize, + GoabCalloutType, + GoabCalloutIconTheme, + Margins, DataAttributes, +} from "@abgov/ui-components-common"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps extends Margins { + heading?: string; + type?: GoabCalloutType; + size?: GoabCalloutSize; + arialive?: GoabCalloutAriaLive; + maxwidth?: string; + icontheme?: GoabCalloutIconTheme; + emphasis?: GoabCalloutEmphasis; + testid?: string; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-callout": WCProps & React.HTMLAttributes; + } + } +} + +export interface GoabxCalloutProps extends Margins, DataAttributes { + heading?: string; + type?: GoabCalloutType; + size?: GoabCalloutSize; + iconTheme?: GoabCalloutIconTheme; + emphasis?: GoabCalloutEmphasis; + maxWidth?: string; + testId?: string; + ariaLive?: GoabCalloutAriaLive; + version?: string; + children?: React.ReactNode; +} + +export const GoabxCallout = ({ + type = "information", + iconTheme = "outline", + size = "large", + ariaLive = "off", + emphasis = "medium", + children, + version = "2", + ...rest +}: GoabxCalloutProps) => { + const _props = transformProps( + { type, icontheme: iconTheme, size, arialive: ariaLive, emphasis, ...rest }, + lowercase + ); + + return ( + + {children} + + ); +}; + +export default GoabxCallout; diff --git a/libs/react-components/src/experimental/checkbox/checkbox.spec.tsx b/libs/react-components/src/experimental/checkbox/checkbox.spec.tsx new file mode 100644 index 0000000000..7ea3d0507e --- /dev/null +++ b/libs/react-components/src/experimental/checkbox/checkbox.spec.tsx @@ -0,0 +1,161 @@ +import { render } from "@testing-library/react"; +import { fireEvent } from "@testing-library/dom"; +import GoabxCheckbox, { Props as CheckboxProps } from "./checkbox"; +import { describe, it, expect, vi } from "vitest"; +import { GoabCheckboxOnChangeDetail } from "@abgov/ui-components-common"; + +const testId = "test-id"; + +describe("GoabxCheckbox", () => { + it("should render", () => { + render(); + + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("name")).toBe("foo"); + expect(checkbox?.getAttribute("disabled")).toBeNull(); + expect(checkbox?.getAttribute("checked")).toBeNull(); + expect(checkbox?.getAttribute("error")).toBeNull(); + }); + + it("should render with props", () => { + const props: CheckboxProps = { + id: "abc", + name: "foo", + value: "bar", + text: "to display", + maxWidth: "480px", + size: "compact", + disabled: true, + checked: true, + error: true, + testId: testId, + mt: "s", + mr: "m", + mb: "l", + ml: "xl", + }; + + render(); + + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("id")).toBe("abc"); + expect(checkbox?.getAttribute("name")).toBe("foo"); + expect(checkbox?.getAttribute("value")).toBe("bar"); + expect(checkbox?.getAttribute("text")).toBe("to display"); + expect(checkbox?.getAttribute("maxwidth")).toBe("480px"); + expect(checkbox?.getAttribute("size")).toBe("compact"); + expect(checkbox?.getAttribute("disabled")).toBe("true"); + expect(checkbox?.getAttribute("checked")).toBe("true"); + expect(checkbox?.getAttribute("error")).toBe("true"); + expect(checkbox?.getAttribute("testid")).toBe(testId); + expect(checkbox?.getAttribute("mt")).toBe("s"); + expect(checkbox?.getAttribute("mr")).toBe("m"); + expect(checkbox?.getAttribute("mb")).toBe("l"); + expect(checkbox?.getAttribute("ml")).toBe("xl"); + }); + + it("should render with boolean value", () => { + render(); + + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("value")).toBe("true"); + expect(checkbox?.getAttribute("disabled")).toBeNull(); + expect(checkbox?.getAttribute("checked")).toBeNull(); + expect(checkbox?.getAttribute("error")).toBeNull(); + }); + + it("should render with text description", () => { + render( + , + ); + + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("description")).toBe("description text"); + expect(checkbox?.getAttribute("checked")).toBeNull(); + }); + + it("should render with slot description", () => { + const result = render( + description slot
} />, + ); + + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("description")).toBe(null); + expect( + result.container.querySelector('div[slot="description"]')?.innerHTML, + ).toContain("description slot"); + }); + + it("should render with slot reveal", () => { + const result = render( + reveal slot} />, + ); + + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("reveal")).toBe(null); + expect( + result.container.querySelector('div[slot="reveal"]')?.innerHTML, + ).toContain("reveal slot"); + }); + + it("should pass the revealAriaLabel property to the web component", () => { + render( + reveal slot} + revealAriaLabel="Screen reader announcement for checkbox reveal content" + />, + ); + + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("revealarialabel")).toBe("Screen reader announcement for checkbox reveal content"); + }); + + it("should handle the onChange event", async function () { + const onChangeStub = vi.fn(); + + function onChange({ name, value, checked }: GoabCheckboxOnChangeDetail) { + expect(name).toBe("foo"); + expect(value).toBe("bar"); + expect(checked).toBeTruthy(); + onChangeStub(); + } + + const props: CheckboxProps = { + name: "foo", + value: "bar", + text: "to display", + disabled: true, + checked: false, + error: false, + onChange: onChange, + testId: testId, + }; + + render(); + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("disabled")).toBe("true"); + expect(checkbox?.getAttribute("checked")).toBeNull(); + expect(checkbox?.getAttribute("error")).toBeNull(); + + checkbox && + fireEvent( + checkbox, + new CustomEvent("_change", { + detail: { name: "foo", value: "bar", checked: true }, + }), + ); + expect(onChangeStub).toBeCalled(); + }); + + it("should pass data-grid attributes", () => { + render( + + ); + const checkbox = document.querySelector("goa-checkbox"); + expect(checkbox?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/checkbox/checkbox.tsx b/libs/react-components/src/experimental/checkbox/checkbox.tsx new file mode 100644 index 0000000000..975e79b828 --- /dev/null +++ b/libs/react-components/src/experimental/checkbox/checkbox.tsx @@ -0,0 +1,123 @@ +import { + DataAttributes, + GoabCheckboxOnChangeDetail, + GoabCheckboxSize, + Margins, +} from "@abgov/ui-components-common"; +import { useEffect, useRef, type JSX } from "react"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-checkbox": WCProps & React.HTMLAttributes & { + ref: React.RefObject; + }; + } + } +} + +interface WCProps extends Margins { + id?: string; + name: string; + checked?: string; + indeterminate?: string; + disabled?: string; + error?: string; + text?: string; + value?: string | number; + arialabel?: string; + description?: string | React.ReactNode; + reveal?: React.ReactNode; + revealarialabel?: string; + maxwidth?: string; + testid?: string; + size?: GoabCheckboxSize; + version?: string; +} + +/* eslint-disable-next-line */ +export interface GoabxCheckboxProps extends Margins, DataAttributes { + id?: string; + name: string; + checked?: boolean; + indeterminate?: boolean; + disabled?: boolean; + error?: boolean; + text?: string; + value?: string | number | boolean; + children?: React.ReactNode; + testId?: string; + ariaLabel?: string; + description?: string | React.ReactNode; + reveal?: React.ReactNode; + revealAriaLabel?: string; + maxWidth?: string; + size?: GoabCheckboxSize; + version?: string; + onChange?: (detail: GoabCheckboxOnChangeDetail) => void; +} + +// legacy +export type Props = GoabxCheckboxProps; + +export function GoabxCheckbox({ + error, + checked, + indeterminate, + disabled, + value, + description, + reveal, + onChange, + name, + children, + size = "default", + version = "2", + ...rest +}: GoabxCheckboxProps): JSX.Element { + const el = useRef(null); + + const _props = transformProps({ size, ...rest }, lowercase); + + useEffect(() => { + if (!el.current) { + return; + } + const current = el.current; + const listener = (e: Event) => { + const detail = (e as CustomEvent).detail; + onChange?.({ ...detail, event: e }); + }; + + current.addEventListener("_change", listener); + + return () => { + current.removeEventListener("_change", listener); + }; + }, [name, onChange]); + + return ( + + {children} + {typeof description !== "string" && description && ( +
{description}
+ )} + {reveal &&
{reveal}
} +
+ ); +} + +export default GoabxCheckbox; diff --git a/libs/react-components/src/experimental/date-picker/date-picker.spec.tsx b/libs/react-components/src/experimental/date-picker/date-picker.spec.tsx new file mode 100644 index 0000000000..92e1ac629d --- /dev/null +++ b/libs/react-components/src/experimental/date-picker/date-picker.spec.tsx @@ -0,0 +1,91 @@ +import { render } from "@testing-library/react"; +import { addMonths } from "date-fns"; +import { describe, it, expect, vi } from "vitest"; + +import DatePicker from "./date-picker"; + +const noop = () => { + /* do nothing */ +}; + +describe("DatePicker", () => { + it("should render", () => { + const { baseElement } = render(); + + const el = baseElement.querySelector("goa-date-picker"); + expect(el).toBeTruthy(); + expect(el?.getAttribute("name")).toBe("foo"); + expect(el?.getAttribute("error")).toBeNull(); + expect(el?.getAttribute("disabled")).toBeNull(); + }); + + it("should render with properties", () => { + 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("error")).toBe("true"); + expect(el?.getAttribute("disabled")).toBe("true"); + expect(el?.getAttribute("min")).toBe(min.toISOString()); + expect(el?.getAttribute("max")).toBe(max.toISOString()); + expect(el?.getAttribute("testid")).toBe("foo"); + expect(el?.getAttribute("type")).toBe("input"); + }); + + it("should handle event", async () => { + const name = "foo"; + const value = new Date(); + const changeEvent = new Event("change"); + + const onChange = vi.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, event: changeEvent }, + }), + ); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toBeCalledWith({ + name, + value, + type: "date", + event: expect.any(Event), + }); + }); + + it("should pass data-grid attributes", () => { + const { baseElement } = render( + , + ); + const el = baseElement.querySelector("goa-date-picker"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/date-picker/date-picker.tsx b/libs/react-components/src/experimental/date-picker/date-picker.tsx new file mode 100644 index 0000000000..881b861f69 --- /dev/null +++ b/libs/react-components/src/experimental/date-picker/date-picker.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, type JSX } from "react"; +import { + GoabDatePickerInputType, + GoabDatePickerOnChangeDetail, + Margins, + DataAttributes, +} from "@abgov/ui-components-common"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps extends Margins { + name?: string; + value?: string; + error?: string; + min?: string; + max?: string; + type?: string; + relative?: string; + disabled?: string; + testid?: string; + width?: string; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-date-picker": WCProps & + React.HTMLAttributes & { + ref: React.RefObject; + }; + } + } +} + +export interface GoabxDatePickerProps extends Margins, DataAttributes { + name?: string; + value?: Date | string | undefined; + error?: boolean; + min?: Date | string; + max?: Date | string; + type?: GoabDatePickerInputType; + testId?: string; + /*** + * @deprecated This property has no effect and will be removed in a future version + */ + relative?: boolean; + disabled?: boolean; + width?: string; + version?: string; + onChange?: (detail: GoabDatePickerOnChangeDetail) => void; +} + +export function GoabxDatePicker({ + value, + error, + min, + max, + disabled, + relative, + version = "2", + onChange, + ...rest +}: GoabxDatePickerProps): JSX.Element { + const ref = useRef(null); + + const _props = transformProps(rest, lowercase); + + useEffect(() => { + if (value && typeof value !== "string") { + console.warn( + "Using a `Date` type for value is deprecated. Instead use a string of the format `yyyy-mm-dd`", + ); + } + }, []); + + useEffect(() => { + if (!ref.current) { + return; + } + const current = ref.current; + + const handleChange = (e: Event) => { + const detail = (e as CustomEvent).detail; + onChange?.({ ...detail, event: e }); + }; + + if (onChange) { + current.addEventListener("_change", handleChange); + } + + return () => { + if (onChange) { + current.removeEventListener("_change", handleChange); + } + }; + }, [onChange]); + + const formatValue = (val: Date | string | undefined) => { + if (!val) return ""; + + if (val instanceof Date) { + return val.toISOString(); + } + + return val; + }; + + return ( + + ); +} + +export default GoabxDatePicker; diff --git a/libs/react-components/src/experimental/drawer/drawer.spec.tsx b/libs/react-components/src/experimental/drawer/drawer.spec.tsx new file mode 100644 index 0000000000..2513071705 --- /dev/null +++ b/libs/react-components/src/experimental/drawer/drawer.spec.tsx @@ -0,0 +1,92 @@ +import { render, waitFor } from "@testing-library/react"; +import { describe, it } from "vitest"; +import GoabxDrawer from "./drawer"; + +const noop = () => { + /* nothing */ +}; + +describe("GoabxDrawer", () => { + it("should render", async () => { + const content = render( + + The content + , + ); + + const el = content.container.querySelector("goa-drawer"); + + expect(el?.getAttribute("position")).toBe("bottom"); + expect(el?.getAttribute("open")).toBeNull(); + }); + + it("should render with properties", async () => { + const content = render( + + The content + , + ); + + const el = content.container.querySelector("goa-drawer"); + expect(el).toBeTruthy(); + await waitFor(() => { + expect(el?.getAttribute("open")).not.toBeNull(); + // TODO: Look in to why this was not working locally + // expect(el?.getAttribute("open")).toBe(true); + expect(el?.getAttribute("position")).toBe("bottom"); + expect(el?.getAttribute("heading")).toBe("The heading"); + expect(el?.getAttribute("maxsize")).toBe("50ch"); + expect(el?.getAttribute("testid")).toBe("the testid"); + }); + }); + + it("renders with React node heading", async () => { + const headingNode =
Custom Heading
; + const content = render( + + The content + , + ); + + const el = content.container.querySelector("goa-drawer"); + expect(el).toBeTruthy(); + await waitFor(() => { + expect(el?.getAttribute("heading")).toBeNull(); + const headingSlot = el?.querySelector('[slot="heading"]'); + expect(headingSlot).toBeTruthy(); + expect(headingSlot?.textContent).toBe("Custom Heading"); + }); + }); + + it("renders with actions", async () => { + const actionsNode = ; + const content = render( + + The content + , + ); + + const el = content.container.querySelector("goa-drawer"); + expect(el).toBeTruthy(); + await waitFor(() => { + const actionsSlot = el?.querySelector('[slot="actions"]'); + expect(actionsSlot).toBeTruthy(); + const actionButton = actionsSlot?.querySelector("button"); + expect(actionButton).toBeTruthy(); + expect(actionButton?.textContent).toBe("Action Button"); + }); + }); +}); diff --git a/libs/react-components/src/experimental/drawer/drawer.tsx b/libs/react-components/src/experimental/drawer/drawer.tsx new file mode 100644 index 0000000000..57d51656b9 --- /dev/null +++ b/libs/react-components/src/experimental/drawer/drawer.tsx @@ -0,0 +1,75 @@ +import { ReactNode, useEffect, useRef, type JSX } from "react"; +import { GoabDrawerPosition, GoabDrawerSize } from "@abgov/ui-components-common"; + +interface WCProps { + position: GoabDrawerPosition; + open?: boolean; + heading?: string; + maxsize?: GoabDrawerSize; + testid?: string; + ref: React.RefObject; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-drawer": WCProps & React.HTMLAttributes; + } + } +} + +export interface GoabxDrawerProps { + position: GoabDrawerPosition; + open?: boolean; + heading?: string | ReactNode; + maxSize?: GoabDrawerSize; + testId?: string; + actions?: ReactNode; + children: ReactNode; + onClose: () => void; + version?: string; +} + +export function GoabxDrawer({ + position, + open, + heading, + maxSize, + testId, + actions, + children, + onClose, + version = "2", +}: GoabxDrawerProps): JSX.Element { + const el = useRef(null); + + useEffect(() => { + if (!el?.current || !onClose) { + return; + } + el.current?.addEventListener("_close", onClose); + return () => { + el.current?.removeEventListener("_close", onClose); + }; + }, [el, onClose]); + + return ( + + {heading && typeof heading !== "string" &&
{heading}
} + {actions &&
{actions}
} + {children} +
+ ); +} + +export default GoabxDrawer; diff --git a/libs/react-components/src/experimental/dropdown/dropdown-item.tsx b/libs/react-components/src/experimental/dropdown/dropdown-item.tsx new file mode 100644 index 0000000000..b1379a5165 --- /dev/null +++ b/libs/react-components/src/experimental/dropdown/dropdown-item.tsx @@ -0,0 +1,58 @@ +import { useEffect } from "react"; +import { GoabDropdownItemMountType } from "@abgov/ui-components-common"; + +interface WCProps { + value: string; + label?: string; + filter?: string; + mount?: GoabDropdownItemMountType; + + // @deprecated + name?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-dropdown-item": WCProps & React.HTMLAttributes; + } + } +} + +export interface GoabxDropdownItemProps { + value: string; + label?: string; + filter?: string; + testId?: string; + mountType?: GoabDropdownItemMountType; + + // @deprecated + name?: string; +} + +export function GoabxDropdownOption(props: GoabxDropdownItemProps) { + useEffect(() => { + console.warn("GoabxDropdownOption is deprecated. Please use GoabxDropdownItem"); + }, []); + + return ; +} + +export function GoabxDropdownItem({ + value, + label, + filter, + name, + mountType = "append", +}: GoabxDropdownItemProps) { + return ( + + ); +} diff --git a/libs/react-components/src/experimental/dropdown/dropdown.spec.tsx b/libs/react-components/src/experimental/dropdown/dropdown.spec.tsx new file mode 100644 index 0000000000..1d2b2585ff --- /dev/null +++ b/libs/react-components/src/experimental/dropdown/dropdown.spec.tsx @@ -0,0 +1,132 @@ +import { render, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { GoabxDropdown } from "./dropdown"; +import { GoabxDropdownItem, GoabxDropdownOption } from "./dropdown-item"; +import { describe, it, expect, vi } from "vitest"; + +const noop = () => { + /* do nothing */ +}; + +afterEach(cleanup); + +describe("GoabxDropdown", () => { + it("should inform the user that GoabxDropdownOption is deprecated", async () => { + const mock = vi.spyOn(console, "warn").mockImplementation(() => { + /* do nothing */ + }); + render( + + + , + ); + + await waitFor(() => { + // @ts-expect-error: console mock + expect(console.warn["mock"].calls.length).toBe(1); + }); + mock.mockRestore(); + }); + + it("should render", async () => { + const { baseElement } = render(); + + const el = baseElement.querySelector("goa-dropdown"); + expect(el?.getAttribute("disabled")).toBeNull(); + expect(el?.getAttribute("error")).toBeNull(); + expect(el?.getAttribute("filterable")).toBeNull(); + expect(el?.getAttribute("multiselect")).toBeNull(); + expect(el?.getAttribute("native")).toBeNull(); + }); + + it("should bind all web-component attributes", async () => { + const { baseElement } = render( + + + + + , + ); + + const el = baseElement.querySelector("goa-dropdown"); + expect(el?.getAttribute("leadingicon")).toBe("color-wand"); + expect(el?.getAttribute("mt")).toBe("s"); + expect(el?.getAttribute("mr")).toBe("m"); + expect(el?.getAttribute("mb")).toBe("l"); + expect(el?.getAttribute("ml")).toBe("xl"); + expect(el?.getAttribute("id")).toBe("foo-dropdown"); + expect(el?.getAttribute("disabled")).toBe("true"); + expect(el?.getAttribute("error")).toBe("true"); + expect(el?.getAttribute("filterable")).toBe("true"); + expect(el?.getAttribute("multiselect")).toBe("true"); + expect(el?.getAttribute("native")).toBe("true"); + expect(el?.getAttribute("arialabel")).toBe("label"); + expect(el?.getAttribute("arialabelledby")).toBe("foo-dropdown-label"); + expect(el?.getAttribute("autocomplete")).toBe("off"); + expect(el?.getAttribute("maxwidth")).toBe("400px"); + expect(el?.getAttribute("size")).toBe("compact"); + }); + + it("should allow for a single selection", async () => { + const fn = vi.fn(); + + const { baseElement } = render( + + + + + , + ); + + const el = baseElement.querySelector("goa-dropdown"); + expect(el).toBeTruthy(); + + el && + fireEvent( + el, + new CustomEvent("_change", { detail: { name: "favColor", value: "blue" } }), + ); + await waitFor(() => { + expect(fn).toBeCalledWith( + expect.objectContaining({ + name: "favColor", + value: "blue", + event: expect.any(Event), + }), + ); + }); + }); + + it("should pass data-grid attributes", () => { + const { baseElement } = render( + + + , + ); + const el = baseElement.querySelector("goa-dropdown"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/dropdown/dropdown.tsx b/libs/react-components/src/experimental/dropdown/dropdown.tsx new file mode 100644 index 0000000000..c1e245d357 --- /dev/null +++ b/libs/react-components/src/experimental/dropdown/dropdown.tsx @@ -0,0 +1,140 @@ +import { + GoabDropdownOnChangeDetail, + GoabDropdownSize, + GoabIconType, + Margins, DataAttributes, +} from "@abgov/ui-components-common"; +import { useEffect, useRef, type JSX } from "react"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps extends Margins { + arialabel?: string; + arialabelledby?: string; + disabled?: string; + error?: string; + filterable?: string; + leadingicon?: string; + maxheight?: string; + multiselect?: string; + name?: string; + native?: string; + placeholder?: string; + value?: string; + width?: string; + maxwidth?: string; + relative?: string; + id?: string; + autocomplete?: string; + testid?: string; + size?: GoabDropdownSize; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface IntrinsicElements { + "goa-dropdown": WCProps & React.HTMLAttributes & { + ref: React.RefObject; + }; + } + } +} + +export interface GoabxDropdownProps extends Margins, DataAttributes { + name?: string; + value?: string[] | string; + onChange?: (detail: GoabDropdownOnChangeDetail) => void; + + // optional + ariaLabel?: string; + ariaLabelledBy?: string; + id?: string; + children?: React.ReactNode; + disabled?: boolean; + error?: boolean; + filterable?: boolean; + leadingIcon?: GoabIconType; + maxHeight?: string; + multiselect?: boolean; + native?: boolean; + placeholder?: string; + testId?: string; + width?: string; + maxWidth?: string; + autoComplete?: string; + size?: GoabDropdownSize; + version?: string; + /*** + * @deprecated This property has no effect and will be removed in a future version + */ + relative?: boolean; +} + +function stringify(value: string | string[] | undefined): string { + if (typeof value === "undefined") { + return ""; + } + if (typeof value === "string") { + return value; + } + return JSON.stringify(value); +} + +export function GoabxDropdown({ + value, + onChange, + disabled, + error, + filterable, + multiselect, + native, + relative, + children, + size = "default", + version = "2", + ...rest +}: GoabxDropdownProps): JSX.Element { + const el = useRef(null); + + const _props = transformProps({ size, ...rest }, lowercase); + + useEffect(() => { + if (!el.current) { + return; + } + const current = el.current; + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + onChange?.({ ...detail, event: e }); + }; + if (onChange) { + current.addEventListener("_change", handler); + } + return () => { + if (onChange) { + current.removeEventListener("_change", handler); + } + }; + }, [el, onChange]); + + return ( + + {children} + + ); +} + +export default GoabxDropdown; diff --git a/libs/react-components/src/experimental/file-upload-card/file-upload-card.spec.tsx b/libs/react-components/src/experimental/file-upload-card/file-upload-card.spec.tsx new file mode 100644 index 0000000000..8bfd172a94 --- /dev/null +++ b/libs/react-components/src/experimental/file-upload-card/file-upload-card.spec.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +import GoabxFileUploadCard from "./file-upload-card"; + +describe("GoabxFileUploadCard", () => { + it("should render with base params", () => { + const { container } = render( + + ); + + const el = container.querySelector("goa-file-upload-card"); + expect(el?.getAttribute("filename")).toBe("foo.png"); + expect(el?.getAttribute("size")).toBe("1000"); + }); + + it("should render with additional params", () => { + const { container } = render( + + ); + + const el = container.querySelector("goa-file-upload-card"); + expect(el?.getAttribute("filename")).toBe("foo.png"); + expect(el?.getAttribute("size")).toBe("1000"); + expect(el?.getAttribute("type")).toBe("image/png"); + expect(el?.getAttribute("progress")).toBe("23"); + expect(el?.getAttribute("error")).toBe("true"); + expect(el?.getAttribute("testid")).toBe("foo"); + }); + + it("dispatches and event when cancel is clicked while uploading", () => { + const onCancel = vi.fn(); + const { container } = render( + + ); + + const el = container.querySelector("goa-file-upload-card"); + el && fireEvent(el, new CustomEvent("_cancel")); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("dispatches and event when delete is clicked and upload is complete", () => { + const onDelete = vi.fn(); + const { container } = render( + + ); + + const el = container.querySelector("goa-file-upload-card"); + el && fireEvent(el, new CustomEvent("_delete")); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("dispatches and event when an error occurs", () => { + const onDelete = vi.fn(); + const { container } = render( + + ); + + const el = container.querySelector("goa-file-upload-card"); + el && fireEvent(el, new CustomEvent("_delete")); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("GoabxFileUploadCard should render without testId (testId is optional)", () => { + const { container } = render( + , + ); + + const el = container.querySelector("goa-file-upload-card"); + expect(el?.getAttribute("filename")).toBe("bar.pdf"); + expect(el?.getAttribute("size")).toBe("1000"); + expect(el?.getAttribute("type")).toBe("application/pdf"); + expect(el?.getAttribute("progress")).toBe("50"); + // testid should be undefined when not provided + expect(el?.getAttribute("testid")).toBeNull(); + }); + + it("should pass data-grid attributes", () => { + const { container } = render( + + ); + const el = container.querySelector("goa-file-upload-card"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); + +}); diff --git a/libs/react-components/src/experimental/file-upload-card/file-upload-card.tsx b/libs/react-components/src/experimental/file-upload-card/file-upload-card.tsx new file mode 100644 index 0000000000..ff9e2138f7 --- /dev/null +++ b/libs/react-components/src/experimental/file-upload-card/file-upload-card.tsx @@ -0,0 +1,72 @@ +import { + DataAttributes, + GoabFileUploadOnCancelDetail, + GoabFileUploadOnDeleteDetail, +} from "@abgov/ui-components-common"; +import { useEffect, useRef } from "react"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps { + filename: string; + size: number; + type?: string; + progress?: number; + error?: string; + testid?: string; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-file-upload-card": WCProps & + React.HTMLAttributes & { + ref: React.RefObject; + }; + } + } +} + +/* eslint-disable-next-line */ +export interface GoabxFileUploadCardProps extends DataAttributes { + filename: string; + size: number; + type?: string; + progress?: number; + testId?: string; + error?: string; + onDelete?: (detail: GoabFileUploadOnDeleteDetail) => void; + onCancel?: (detail: GoabFileUploadOnCancelDetail) => void; + version?: string; +} + +export function GoabxFileUploadCard({ + onDelete, + onCancel, + filename, + version = "2", + ...rest +}: GoabxFileUploadCardProps) { + const el = useRef(null); + + const _props = transformProps({ filename, ...rest }, lowercase); + + useEffect(() => { + if (!el.current) return; + + const current = el.current; + const deleteHandler = (event: Event) => onDelete?.({ filename, event }); + const cancelHandler = (event: Event) => onCancel?.({ filename, event }); + current.addEventListener("_delete", deleteHandler); + current.addEventListener("_cancel", cancelHandler); + return () => { + current.removeEventListener("_delete", deleteHandler); + current.removeEventListener("_cancel", cancelHandler); + }; + }, [el, onDelete, onCancel, filename]); + + return ; +} + +export default GoabxFileUploadCard; diff --git a/libs/react-components/src/experimental/file-upload-input/file-upload-input.spec.tsx b/libs/react-components/src/experimental/file-upload-input/file-upload-input.spec.tsx new file mode 100644 index 0000000000..6204d2fb80 --- /dev/null +++ b/libs/react-components/src/experimental/file-upload-input/file-upload-input.spec.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +import GoabxFileUploadInput from "./file-upload-input"; + +const noop = () => { /* do nothing */ }; + +describe("GoabxFileUploadInput", () => { + it("should render successfully", () => { + const { baseElement } = render( + + ); + const el = baseElement.querySelector("goa-file-upload-input"); + + expect(el?.getAttribute("maxfilesize")).toBe("10MB"); + expect(el?.getAttribute("accept")).toBe("image/*"); + expect(el?.getAttribute("variant")).toBe("dragdrop"); + expect(el?.getAttribute("testid")).toBe("foo"); + }); + + it("handles the onSelectFile event", () => { + const onSelect = vi.fn(); + const { baseElement } = render(); + const el = baseElement.querySelector("goa-file-upload-input"); + el && fireEvent(el, new CustomEvent("_selectFile", { detail: {} })); + + expect(onSelect).toBeCalled(); + }); + + it("should pass data-grid attributes", () => { + const { baseElement } = render( + + ); + const el = baseElement.querySelector("goa-file-upload-input"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/file-upload-input/file-upload-input.tsx b/libs/react-components/src/experimental/file-upload-input/file-upload-input.tsx new file mode 100644 index 0000000000..a53d37952f --- /dev/null +++ b/libs/react-components/src/experimental/file-upload-input/file-upload-input.tsx @@ -0,0 +1,66 @@ +import { + DataAttributes, + GoabFileUploadInputOnSelectFileDetail, + GoabFileUploadInputVariant, +} from "@abgov/ui-components-common"; +import { useEffect, useRef } from "react"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps { + variant?: GoabFileUploadInputVariant; + accept?: string; + maxfilesize?: string; + testid?: string; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-file-upload-input": WCProps & React.HTMLAttributes & { + ref: React.RefObject; + }; + } + } +} + +/* eslint-disable-next-line */ +export interface GoabxFileUploadInputProps extends DataAttributes { + variant?: GoabFileUploadInputVariant; + accept?: string; + maxFileSize?: string; + testId?: string; + onSelectFile: (detail: GoabFileUploadInputOnSelectFileDetail) => void; + version?: string; +} + +export function GoabxFileUploadInput({ + onSelectFile, + version = "2", + ...rest +}: GoabxFileUploadInputProps) { + const el = useRef(null); + + const _props = transformProps(rest, lowercase); + + useEffect(() => { + if (!el.current) return; + + const current = el.current; + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + onSelectFile({ ...detail, event: e }); + }; + current.addEventListener("_selectFile", handler); + return () => { + current.removeEventListener("_selectFile", handler); + }; + }, [el, onSelectFile]); + + return ( + + ); +} + +export default GoabxFileUploadInput; diff --git a/libs/react-components/src/experimental/filter-chip/filter-chip.spec.tsx b/libs/react-components/src/experimental/filter-chip/filter-chip.spec.tsx new file mode 100644 index 0000000000..65674d2c4f --- /dev/null +++ b/libs/react-components/src/experimental/filter-chip/filter-chip.spec.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { GoabxFilterChip } from "./filter-chip"; +import { describe, it, expect, vi } from "vitest"; + +describe("GoabxFilterChip", () => { + it("should render", () => { + const { container } = render(); + + const el = container.querySelector("goa-filter-chip"); + expect(el?.getAttribute("content")).toBe("some filter chip"); + expect(el?.getAttribute("error")).toBeNull(); + }); + + it("should bind all properties correctly", async () => { + const { container } = render( + , + ); + + const el = container.querySelector("goa-filter-chip"); + + expect(el?.getAttribute("content")).toBe("some filter chip"); + expect(el?.getAttribute("mt")).toBe("s"); + expect(el?.getAttribute("mr")).toBe("m"); + expect(el?.getAttribute("mb")).toBe("l"); + expect(el?.getAttribute("ml")).toBe("xl"); + expect(el?.getAttribute("error")).toBe("true"); + expect(el?.getAttribute("icontheme")).toBe("filled"); + expect(el?.getAttribute("secondarytext")).toBe("secondary text"); + expect(el?.getAttribute("leadingicon")).toBe("accessibility"); + expect(el?.getAttribute("testid")).toBe("test-chip"); + }); + + it("should show the chip in the error state", () => { + const { container } = render(); + const el = container.querySelector("goa-filter-chip"); + expect(el?.getAttribute("error")).toBe("true"); + }); + + it("should handle the click event", async () => { + const onClick = vi.fn(); + const { container } = render( + , + ); + const el = container.querySelector("goa-filter-chip"); + + el && fireEvent(el, new CustomEvent("_click")); + expect(onClick).toHaveBeenCalled(); + }); + + it("should have an unfilled close icon by default", () => { + const { container } = render(); + const el = container.querySelector("goa-filter-chip"); + expect(el?.getAttribute("icontheme")).toBe("outline"); + }); + + // This test was passing due to a false positive + it.skip("should not apply background fill on hover", async () => { + const { container } = render(); + const chip = container.querySelector("goa-filter-chip"); + fireEvent.mouseOver(chip!); + expect(chip).not.toHaveStyle("background-color: var(--goa-color-greyscale-200)"); + }); + + it("should pass data-grid attributes", () => { + const { container } = render( + + ); + const el = container.querySelector("goa-filter-chip"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/filter-chip/filter-chip.tsx b/libs/react-components/src/experimental/filter-chip/filter-chip.tsx new file mode 100644 index 0000000000..b179b4069e --- /dev/null +++ b/libs/react-components/src/experimental/filter-chip/filter-chip.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; +import { + DataAttributes, + GoabFilterChipTheme, + GoabIconType, + Margins, +} from "@abgov/ui-components-common"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps extends Margins { + icontheme: GoabFilterChipTheme; + error?: string; + content: string; + secondarytext?: string; + leadingicon?: GoabIconType; + testid?: string; + version?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-filter-chip": WCProps & React.HTMLAttributes & { + ref: React.RefObject; + }; + } + } +} + +export interface GoabxFilterChipProps extends Margins, DataAttributes { + onClick?: () => void; + iconTheme?: GoabFilterChipTheme; + error?: boolean; + content: string; + secondaryText?: string; + leadingIcon?: GoabIconType; + testId?: string; + version?: string; +} + +export const GoabxFilterChip = ({ + iconTheme = "outline", + error, + onClick, + version = "2", + ...rest +}: GoabxFilterChipProps) => { + const el = useRef(null); + + const _props = transformProps( + { icontheme: iconTheme, ...rest }, + lowercase + ); + + useEffect(() => { + if (!el.current) return; + if (!onClick) return; + + const current = el.current; + + current.addEventListener("_click", onClick); + return () => { + current.removeEventListener("_click", onClick!); + }; + }, [el, onClick]); + + return ( + + ); +}; + +export default GoabxFilterChip; diff --git a/libs/react-components/src/experimental/footer-meta-section/footer-meta-section.spec.tsx b/libs/react-components/src/experimental/footer-meta-section/footer-meta-section.spec.tsx new file mode 100644 index 0000000000..854d1b1e32 --- /dev/null +++ b/libs/react-components/src/experimental/footer-meta-section/footer-meta-section.spec.tsx @@ -0,0 +1,24 @@ +import { render } from "@testing-library/react"; + +import FooterMetaSection from "./footer-meta-section"; + +describe("FooterMetaSection", () => { + it("should render successfully", () => { + const { baseElement } = render(); + const el = baseElement.querySelector("goa-app-footer-meta-section"); + expect(baseElement).toBeTruthy(); + expect(el?.getAttribute("testid")).toBe("foo"); + }); + + it("should pass data-grid attributes", () => { + const { baseElement } = render( + + Meta content + + ); + const el = baseElement.querySelector("goa-app-footer-meta-section"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/footer-meta-section/footer-meta-section.tsx b/libs/react-components/src/experimental/footer-meta-section/footer-meta-section.tsx new file mode 100644 index 0000000000..c7354186e2 --- /dev/null +++ b/libs/react-components/src/experimental/footer-meta-section/footer-meta-section.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from "react"; +import { DataAttributes } from "@abgov/ui-components-common"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps { + testid?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-app-footer-meta-section": WCProps & React.HTMLAttributes; + } + } +} + +/* eslint-disable-next-line */ +export interface GoabxAppFooterMetaSectionProps extends DataAttributes { + testId?: string; + children?: ReactNode; +} + +export function GoabxAppFooterMetaSection({ + children, + ...rest +}: GoabxAppFooterMetaSectionProps) { + const _props = transformProps(rest, lowercase); + + return ( + + {children} + + ); +} + +export default GoabxAppFooterMetaSection; diff --git a/libs/react-components/src/experimental/footer-nav-section/footer-nav-section.spec.tsx b/libs/react-components/src/experimental/footer-nav-section/footer-nav-section.spec.tsx new file mode 100644 index 0000000000..c2e7b16319 --- /dev/null +++ b/libs/react-components/src/experimental/footer-nav-section/footer-nav-section.spec.tsx @@ -0,0 +1,23 @@ +import { render } from "@testing-library/react"; + +import FooterNavSection from "./footer-nav-section"; + +describe("FooterNavSection", () => { + it("should render successfully", () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + + it("should pass data-grid attributes", () => { + const { baseElement } = render( + + Nav content + + ); + const el = baseElement.querySelector("goa-app-footer-nav-section"); + expect(el?.getAttribute("data-grid")).toBe("cell"); + }); +}); diff --git a/libs/react-components/src/experimental/footer-nav-section/footer-nav-section.tsx b/libs/react-components/src/experimental/footer-nav-section/footer-nav-section.tsx new file mode 100644 index 0000000000..6c6f84c49e --- /dev/null +++ b/libs/react-components/src/experimental/footer-nav-section/footer-nav-section.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from "react"; +import { DataAttributes } from "@abgov/ui-components-common"; +import { transformProps, lowercase } from "../../lib/common/extract-props"; + +interface WCProps { + maxcolumncount?: number; + heading?: string; + testid?: string; +} + +declare module "react" { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + "goa-app-footer-nav-section": WCProps & React.HTMLAttributes; + } + } +} + +/* eslint-disable-next-line */ +export interface GoabxFooterNavSectionProps extends DataAttributes { + maxColumnCount?: number; + heading?: string; + testId?: string; + children?: ReactNode; +} + +export function GoabxAppFooterNavSection({ + children, + ...rest +}: GoabxFooterNavSectionProps) { + const _props = transformProps(rest, lowercase); + + return ( + + {children} + + ); +} + +export default GoabxAppFooterNavSection; diff --git a/libs/react-components/src/experimental/footer/footer.spec.tsx b/libs/react-components/src/experimental/footer/footer.spec.tsx new file mode 100644 index 0000000000..f5957f4774 --- /dev/null +++ b/libs/react-components/src/experimental/footer/footer.spec.tsx @@ -0,0 +1,22 @@ +import { render } from "@testing-library/react"; + +import Footer from "./footer"; + +describe("Footer", () => { + it("should render successfully", () => { + const { baseElement } = render(