diff --git a/.gitignore b/.gitignore index 3a1a5d1105..f554e8d291 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ # Custom .nx -playground -.playgrounds +apps/dev/angular +apps/dev/react +apps/dev/web NOTES.md /test-results __screenshots__ @@ -67,4 +68,4 @@ devenv.local.nix vite.config.*.timestamp* vitest.config.*.timestamp* .cursor/rules/nx-rules.mdc -.github/instructions/nx.instructions.md \ No newline at end of file +.github/instructions/nx.instructions.md diff --git a/.nxignore b/.nxignore index a60609e6c6..7706a47b69 100644 --- a/.nxignore +++ b/.nxignore @@ -1,2 +1,4 @@ -!playground **/.*/ +!apps/dev/web +!apps/dev/react +!apps/dev/angular diff --git a/.templates/angular/.eslintrc.json b/.templates/angular/.eslintrc.json index 98a83993d8..96b5a50035 100644 --- a/.templates/angular/.eslintrc.json +++ b/.templates/angular/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["../../.eslintrc.json"], + "extends": ["../../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/.templates/angular/project.json b/.templates/angular/project.json index 4e3b1309ab..f7a039a435 100644 --- a/.templates/angular/project.json +++ b/.templates/angular/project.json @@ -1,25 +1,25 @@ { - "name": "angular", - "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "angular-dev", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "prefix": "abgov", - "sourceRoot": "playground/angular/src", + "sourceRoot": "apps/dev/angular/src", "tags": [], "targets": { "build": { "executor": "@angular-devkit/build-angular:application", "outputs": ["{options.outputPath}"], "options": { - "outputPath": "dist/playground/angular", - "index": "playground/angular/src/index.html", - "browser": "playground/angular/src/main.ts", + "outputPath": "dist/apps/dev/angular", + "index": "apps/dev/angular/src/index.html", + "browser": "apps/dev/angular/src/main.ts", "polyfills": ["zone.js"], - "tsConfig": "playground/angular/tsconfig.app.json", + "tsConfig": "apps/dev/angular/tsconfig.app.json", "assets": [ - "playground/angular/src/favicon.ico", - "playground/angular/src/assets" + "apps/dev/angular/src/favicon.ico", + "apps/dev/angular/src/assets" ], - "styles": ["playground/angular/src/styles.css"], + "styles": ["apps/dev/angular/src/styles.css"], "scripts": [] }, "configurations": { @@ -27,8 +27,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kb", - "maximumError": "1mb" + "maximumWarning": "2mb", + "maximumError": "3mb" }, { "type": "anyComponentStyle", @@ -50,10 +50,10 @@ "executor": "@angular-devkit/build-angular:dev-server", "configurations": { "production": { - "buildTarget": "angular:build:production" + "buildTarget": "angular-dev:build:production" }, "development": { - "buildTarget": "angular:build:development" + "buildTarget": "angular-dev:build:development" } }, "defaultConfiguration": "development" @@ -61,7 +61,7 @@ "extract-i18n": { "executor": "@angular-devkit/build-angular:extract-i18n", "options": { - "buildTarget": "angular:build" + "buildTarget": "angular-dev:build" } }, "lint": { diff --git a/.templates/angular/src/app/app-routing.module.ts b/.templates/angular/src/app/app-routing.module.ts index 75dcd69f94..4819a90925 100644 --- a/.templates/angular/src/app/app-routing.module.ts +++ b/.templates/angular/src/app/app-routing.module.ts @@ -1,13 +1,11 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; -import { ComponentWrapperPageComponent } from "./component-wrapper"; +import { PlaygroundComponent } from "./playground"; -const routes: Routes = [ - { path: "/", component: ComponentWrapperPageComponent }, -]; +const routes: Routes = [{ path: "", component: PlaygroundComponent }]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule], }) -export class AppRoutingModule { } +export class AppRoutingModule {} diff --git a/.templates/angular/src/app/app.component.ts b/.templates/angular/src/app/app.component.ts index 570983ee54..4195567bae 100644 --- a/.templates/angular/src/app/app.component.ts +++ b/.templates/angular/src/app/app.component.ts @@ -1,8 +1,11 @@ -import {Component, OnInit} from "@angular/core"; +import { Component, OnInit } from "@angular/core"; +import { RouterOutlet } from "@angular/router"; @Component({ - selector: "goab-root", - template: "" + selector: "abgov-root", + template: "", + standalone: true, + imports: [RouterOutlet], }) export class AppComponent implements OnInit { ngOnInit() { diff --git a/.templates/angular/src/app/app.module.ts b/.templates/angular/src/app/app.module.ts index c831cabce3..188e309b77 100644 --- a/.templates/angular/src/app/app.module.ts +++ b/.templates/angular/src/app/app.module.ts @@ -6,17 +6,18 @@ import { AngularComponentsModule } from "@abgov/angular-components"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { NgForOf, NgIf } from "@angular/common"; -import { ComponentWrapperPageComponent } from "./component-wrapper"; +import { PlaygroundComponent } from "./playground"; +import { AppRoutingModule } from "./app-routing.module"; import "@abgov/web-components"; @NgModule({ - declarations: [ - AppComponent, - ], + declarations: [], imports: [ + AppComponent, AngularComponentsModule, - ComponentWrapperPageComponent, + PlaygroundComponent, BrowserModule, + AppRoutingModule, FormsModule, NgForOf, NgIf, @@ -27,4 +28,4 @@ import "@abgov/web-components"; bootstrap: [AppComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], }) -export class AppModule { } +export class AppModule {} diff --git a/.templates/angular/src/app/component-wrapper.ts b/.templates/angular/src/app/component-wrapper.ts deleted file mode 100644 index 7f22d9e904..0000000000 --- a/.templates/angular/src/app/component-wrapper.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core"; -import { CommonModule } from "@angular/common"; -import { - GoabAccordion, - GoabAppFooter, - GoabAppFooterMetaSection, - GoabAppFooterNavSection, - GoabAppHeader, - GoabAppHeaderMenu, - GoabBadge, - GoabBlock, - GoabButton, - GoabButtonGroup, - GoabCallout, - GoabCheckbox, - GoabChip, - GoabCircularProgress, - GoabContainer, - GoabDatePicker, - GoabDetails, - GoabDivider, - GoabDropdown, - GoabDropdownItem, - GoabFileUploadCard, - GoabFileUploadInput, - GoabFormItem, - GoabFormStep, - GoabFormStepper, - GoabHeroBanner, - GoabIcon, - GoabIconButton, - GoabInput, - GoabModal, - GoabNotification, - GoabPages, - GoabPagination, - GoabPopover, - GoabRadioGroup, - GoabRadioItem, - GoabSideMenu, - GoabSideMenuGroup, - GoabSideMenuHeading, - GoabSkeleton, - GoabTab, - GoabTable, - GoabTabs, - GoabTooltip, - GoabSpacer, - GoabTextArea, -} from "@abgov/angular-components"; - -@Component({ - standalone: true, - selector: "abgov-component-wrapper", - template: ` -
- -
- `, - - schemas: [CUSTOM_ELEMENTS_SCHEMA], - imports: [ - CommonModule, - GoabBadge, - GoabButton, - GoabDropdown, - GoabDropdownItem, - GoabDatePicker, - GoabModal, - GoabAccordion, - GoabAppFooter, - GoabAppFooterMetaSection, - GoabAppFooterNavSection, - GoabAppHeader, - GoabAppHeaderMenu, - GoabButtonGroup, - GoabCallout, - GoabCheckbox, - GoabChip, - GoabContainer, - GoabDetails, - GoabDivider, - GoabFileUploadCard, - GoabFileUploadInput, - GoabFormItem, - GoabFormStep, - GoabFormStepper, - GoabPages, - GoabHeroBanner, - GoabIcon, - GoabIconButton, - GoabInput, - GoabNotification, - GoabPagination, - GoabPopover, - GoabCircularProgress, - GoabRadioGroup, - GoabRadioItem, - GoabSideMenu, - GoabSideMenuGroup, - GoabSideMenuHeading, - GoabSkeleton, - GoabTable, - GoabTabs, - GoabTab, - GoabTextArea, - GoabTooltip, - GoabBlock, - GoabSpacer, - ], -}) -export class ComponentWrapperPageComponent {} diff --git a/.templates/angular/src/app/playground.ts b/.templates/angular/src/app/playground.ts new file mode 100644 index 0000000000..cab524670c --- /dev/null +++ b/.templates/angular/src/app/playground.ts @@ -0,0 +1,24 @@ +import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + GoabInput, + /* Import components here */ +} from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-app", + template: ` +
+ +
+ `, + + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [ + CommonModule, + GoabInput, + // add test components here + ], +}) +export class PlaygroundComponent {} diff --git a/.templates/angular/src/index.html b/.templates/angular/src/index.html index d34968612b..1b9821ebb2 100644 --- a/.templates/angular/src/index.html +++ b/.templates/angular/src/index.html @@ -12,7 +12,7 @@ - + diff --git a/.templates/angular/src/styles.css b/.templates/angular/src/styles.css index 20b8e4485b..963ef40b19 100644 --- a/.templates/angular/src/styles.css +++ b/.templates/angular/src/styles.css @@ -1,5 +1,5 @@ /* You can add global styles to this file, and also import other style files */ -@import "../../../dist/libs/web-components/index.css"; +@import "../../../../dist/libs/web-components/index.css"; :root { --goa-space-fill: 32ch; diff --git a/.templates/angular/tsconfig.app.json b/.templates/angular/tsconfig.app.json index fff4a41d44..58220429a4 100644 --- a/.templates/angular/tsconfig.app.json +++ b/.templates/angular/tsconfig.app.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../../dist/out-tsc", "types": [] }, "files": ["src/main.ts"], diff --git a/.templates/angular/tsconfig.json b/.templates/angular/tsconfig.json index cd3727d6fb..b94f8837df 100644 --- a/.templates/angular/tsconfig.json +++ b/.templates/angular/tsconfig.json @@ -20,7 +20,7 @@ "path": "./tsconfig.editor.json" } ], - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, "strictInjectionParameters": true, diff --git a/.templates/react/.eslintrc.json b/.templates/react/.eslintrc.json index a39ac5d057..75b85077de 100644 --- a/.templates/react/.eslintrc.json +++ b/.templates/react/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/.templates/react/project.json b/.templates/react/project.json index 2a15c2f164..acef9c1824 100644 --- a/.templates/react/project.json +++ b/.templates/react/project.json @@ -1,7 +1,7 @@ { - "name": "react", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "playground/react/src", + "name": "react-dev", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/dev/react/src", "projectType": "application", "targets": { "build": { @@ -9,7 +9,7 @@ "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { - "outputPath": "dist/playground/react" + "outputPath": "dist/apps/dev/react" }, "configurations": { "development": { @@ -24,15 +24,15 @@ "executor": "@nx/vite:dev-server", "defaultConfiguration": "development", "options": { - "buildTarget": "react:build" + "buildTarget": "react-dev:build" }, "configurations": { "development": { - "buildTarget": "react:build:development", + "buildTarget": "react-dev:build:development", "hmr": true }, "production": { - "buildTarget": "react:build:production", + "buildTarget": "react-dev:build:production", "hmr": false } } @@ -41,14 +41,14 @@ "executor": "@nx/vite:preview-server", "defaultConfiguration": "development", "options": { - "buildTarget": "react:build" + "buildTarget": "react-dev:build" }, "configurations": { "development": { - "buildTarget": "react:build:development" + "buildTarget": "react-dev:build:development" }, "production": { - "buildTarget": "react:build:production" + "buildTarget": "react-dev:build:production" } } }, diff --git a/.templates/react/src/app/Playground.tsx b/.templates/react/src/app/Playground.tsx new file mode 100644 index 0000000000..2fcf6f266b --- /dev/null +++ b/.templates/react/src/app/Playground.tsx @@ -0,0 +1,8 @@ +export default function Playground() { + + return ( +
+ The playground +
+ ); +} diff --git a/.templates/react/src/app/TestContent.tsx b/.templates/react/src/app/TestContent.tsx deleted file mode 100644 index ec7c011b4f..0000000000 --- a/.templates/react/src/app/TestContent.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export function TestContent() { - - return ( -
- -
- ); -} diff --git a/.templates/react/src/app/app.tsx b/.templates/react/src/app/app.tsx index 283a56cd71..a84b5d650e 100644 --- a/.templates/react/src/app/app.tsx +++ b/.templates/react/src/app/app.tsx @@ -1,6 +1,4 @@ -import { Link, Outlet } from "react-router-dom"; - -import { TestContent } from "./TestContent"; +import { Outlet } from "react-router-dom"; import { GoabAppFooter, @@ -18,7 +16,7 @@ export function App() { View All - Test + Playground
diff --git a/.templates/react/src/main.tsx b/.templates/react/src/main.tsx index c8ffcb27e4..e0c3886a8c 100644 --- a/.templates/react/src/main.tsx +++ b/.templates/react/src/main.tsx @@ -5,7 +5,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import "@abgov/web-components"; import App from "./app/app"; import AllComponents from "./app/all"; -import { TestContent } from "./app/TestContent"; +import Playground from "./app/Playground"; const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement, @@ -18,7 +18,7 @@ root.render( }> } /> - } /> + } /> diff --git a/.templates/react/tsconfig.app.json b/.templates/react/tsconfig.app.json index cd44a1e78f..2a5ff5ebd5 100644 --- a/.templates/react/tsconfig.app.json +++ b/.templates/react/tsconfig.app.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../../dist/out-tsc", "types": [ "node", "@nx/react/typings/cssmodule.d.ts", diff --git a/.templates/react/tsconfig.json b/.templates/react/tsconfig.json index fe609d7af3..4467b39320 100644 --- a/.templates/react/tsconfig.json +++ b/.templates/react/tsconfig.json @@ -14,5 +14,5 @@ "path": "./tsconfig.app.json" } ], - "extends": "../../tsconfig.base.json" + "extends": "../../../tsconfig.base.json" } diff --git a/.templates/react/vite.config.ts b/.templates/react/vite.config.ts index d7c536102c..f77894c6f3 100644 --- a/.templates/react/vite.config.ts +++ b/.templates/react/vite.config.ts @@ -5,16 +5,20 @@ import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; export default defineConfig({ root: __dirname, - cacheDir: "../../node_modules/.vite/playground/react", + cacheDir: "../../../node_modules/.vite/playground/react", server: { - port: 4200, + port: 4201, host: "0.0.0.0", + // Enable SPA fallback for client-side routing + historyApiFallback: true, }, preview: { port: 4300, host: "localhost", + // Enable SPA fallback in preview mode too + historyApiFallback: true, }, plugins: [react(), nxViteTsPaths()], @@ -25,7 +29,7 @@ export default defineConfig({ // }, build: { - outDir: "../../dist/playground/react", + outDir: "../../../dist/apps/prs/react", reportCompressedSize: true, minify: false, commonjsOptions: { diff --git a/.templates/web/.eslintrc.json b/.templates/web/.eslintrc.json index 9d9c0db55b..3456be9b90 100644 --- a/.templates/web/.eslintrc.json +++ b/.templates/web/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["../../.eslintrc.json"], + "extends": ["../../../.eslintrc.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/.templates/web/project.json b/.templates/web/project.json index 33855a6641..c870a0f113 100644 --- a/.templates/web/project.json +++ b/.templates/web/project.json @@ -1,8 +1,8 @@ { - "name": "web", - "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "web-dev", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "sourceRoot": "playground/web/src", + "sourceRoot": "apps/dev/web/src", "tags": [], "implicitDependencies": [ "web-components" @@ -15,7 +15,7 @@ ], "defaultConfiguration": "production", "options": { - "outputPath": "dist/playground/web" + "outputPath": "dist/apps/dev/web" }, "configurations": { "development": { @@ -30,15 +30,15 @@ "executor": "@nx/vite:dev-server", "defaultConfiguration": "development", "options": { - "buildTarget": "web:build" + "buildTarget": "web-dev:build" }, "configurations": { "development": { - "buildTarget": "web:build:development", + "buildTarget": "web-dev:build:development", "hmr": true }, "production": { - "buildTarget": "web:build:production", + "buildTarget": "web-dev:build:production", "hmr": false } } @@ -47,14 +47,14 @@ "executor": "@nx/vite:preview-server", "defaultConfiguration": "development", "options": { - "buildTarget": "web:build" + "buildTarget": "web-dev:build" }, "configurations": { "development": { - "buildTarget": "web:build:development" + "buildTarget": "web-dev:build:development" }, "production": { - "buildTarget": "web:build:production" + "buildTarget": "web-dev:build:production" } } }, @@ -65,4 +65,4 @@ ] } } -} \ No newline at end of file +} diff --git a/.templates/web/src/app/App.svelte b/.templates/web/src/app/App.svelte index dd0f1ec021..ce9aef4429 100644 --- a/.templates/web/src/app/App.svelte +++ b/.templates/web/src/app/App.svelte @@ -4,5 +4,10 @@ + + + + diff --git a/.templates/web/src/app/Playground.svelte b/.templates/web/src/app/Playground.svelte new file mode 100644 index 0000000000..46962b165b --- /dev/null +++ b/.templates/web/src/app/Playground.svelte @@ -0,0 +1,6 @@ + + + +The web playground diff --git a/.templates/web/tsconfig.app.json b/.templates/web/tsconfig.app.json index 031e9c720f..5a6d3e61df 100644 --- a/.templates/web/tsconfig.app.json +++ b/.templates/web/tsconfig.app.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../../dist/out-tsc", "declaration": true, "types": ["node"] }, diff --git a/.templates/web/tsconfig.json b/.templates/web/tsconfig.json index af79c85a6c..715e5cd65f 100644 --- a/.templates/web/tsconfig.json +++ b/.templates/web/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "files": [], "compilerOptions": { "target": "ESNext", diff --git a/.templates/web/vite.config.mts b/.templates/web/vite.config.mts index 4ada67c6b1..8d35eda1ad 100644 --- a/.templates/web/vite.config.mts +++ b/.templates/web/vite.config.mts @@ -1,26 +1,27 @@ /// import { defineConfig } from "vite"; import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; -import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { svelte } from "@sveltejs/vite-plugin-svelte"; export default defineConfig({ root: __dirname, - cacheDir: "../../node_modules/.vite/playground/web", + cacheDir: "../../node_modules/.vite/apps/prs-web", server: { - port: 4200, + port: 4202, host: "0.0.0.0", + // Enable SPA fallback for client-side routing + historyApiFallback: true, }, preview: { port: 4300, host: "localhost", + // Enable SPA fallback in preview mode too + historyApiFallback: true, }, - plugins: [ - nxViteTsPaths(), - svelte(), - ], + plugins: [nxViteTsPaths(), svelte()], // Uncomment this if you are using workers. // worker: { @@ -28,7 +29,7 @@ export default defineConfig({ // }, build: { - outDir: "../../dist/playground/web", + outDir: "../../dist/apps/prs-web", reportCompressedSize: true, commonjsOptions: { transformMixedEsModules: true, diff --git a/README.md b/README.md index 8590658a50..e5d744a892 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,12 @@ 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. -## Development environment - -Create .env file from .env.example - -```bash -if [ ! -f .env ]; then cp ./.env.example ./.env; fi -``` - ### Playground setup -Run the `pg-setup` file. +Run the `dev-setup` file. ```bash -npm run pg:setup +npm run dev:setup ``` You can then test the playground apps at `localhost:4200` by running: @@ -29,28 +21,9 @@ You can then test the playground apps at `localhost:4200` by running: npm run dev:watch # add one of the following -npm run serve:angular -npm run serve:react -npm run serve:web -``` - -### Multiple playgrounds - -Since the playground is not included in the CVS it is common to have playground -comment/uncomment code the `npm run pg:switch` script can automate this by switching -code that is out of sync with library code. To prevent having to continually -between playgrounds that are specific to the branch. - -To switch to a branch that doesn't yet exist, run the following - -```bash -npm run pg:switch new [branch-name] -``` - -To switch to an existing branch run the following - -```bash -npm run pg:switch [branch-name] +npm run serve:dev:angular +npm run serve:dev:react +npm run serve:dev:web ``` --- diff --git a/apps/prs/angular/.eslintrc.json b/apps/prs/angular/.eslintrc.json new file mode 100644 index 0000000000..96b5a50035 --- /dev/null +++ b/apps/prs/angular/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "abgov", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "abgov", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/apps/prs/angular/.gitignore b/apps/prs/angular/.gitignore new file mode 100644 index 0000000000..4d058db7df --- /dev/null +++ b/apps/prs/angular/.gitignore @@ -0,0 +1,9 @@ +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/apps/prs/angular/angular.json b/apps/prs/angular/angular.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/apps/prs/angular/angular.json @@ -0,0 +1 @@ +{} diff --git a/apps/prs/angular/project.json b/apps/prs/angular/project.json new file mode 100644 index 0000000000..4ace99a7fc --- /dev/null +++ b/apps/prs/angular/project.json @@ -0,0 +1,72 @@ +{ + "name": "angular-prs", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "abgov", + "sourceRoot": "apps/prs/angular/src", + "tags": [], + "targets": { + "build": { + "executor": "@angular-devkit/build-angular:application", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/prs/angular", + "index": "apps/prs/angular/src/index.html", + "browser": "apps/prs/angular/src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "apps/prs/angular/tsconfig.app.json", + "assets": [ + "apps/prs/angular/src/favicon.ico", + "apps/prs/angular/src/assets" + ], + "styles": ["apps/prs/angular/src/styles.css"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "3mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "executor": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "angular-prs:build:production" + }, + "development": { + "buildTarget": "angular-prs:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "executor": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "angular-prs:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/apps/prs/angular/src/app/abgov-app.ts b/apps/prs/angular/src/app/abgov-app.ts new file mode 100644 index 0000000000..3ab16c2e39 --- /dev/null +++ b/apps/prs/angular/src/app/abgov-app.ts @@ -0,0 +1,17 @@ +import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import {} from "@abgov/angular-components"; + +@Component({ + standalone: true, + selector: "abgov-app", + template: ` +
+ +
+ `, + + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule], +}) +export class AbgovAppComponent {} diff --git a/apps/prs/angular/src/app/app-routing.module.ts b/apps/prs/angular/src/app/app-routing.module.ts new file mode 100644 index 0000000000..80f2be5250 --- /dev/null +++ b/apps/prs/angular/src/app/app-routing.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; +import { AbgovAppComponent } from "./abgov-app"; + +const routes: Routes = [ + { path: "", component: AbgovAppComponent }, + // add custom paths here +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule], +}) +export class AppRoutingModule {} diff --git a/apps/prs/angular/src/app/app.component.ts b/apps/prs/angular/src/app/app.component.ts new file mode 100644 index 0000000000..4195567bae --- /dev/null +++ b/apps/prs/angular/src/app/app.component.ts @@ -0,0 +1,14 @@ +import { Component, OnInit } from "@angular/core"; +import { RouterOutlet } from "@angular/router"; + +@Component({ + selector: "abgov-root", + template: "", + standalone: true, + imports: [RouterOutlet], +}) +export class AppComponent implements OnInit { + ngOnInit() { + console.log("Hello from Angular"); + } +} diff --git a/apps/prs/angular/src/app/app.module.ts b/apps/prs/angular/src/app/app.module.ts new file mode 100644 index 0000000000..15a4f9fa89 --- /dev/null +++ b/apps/prs/angular/src/app/app.module.ts @@ -0,0 +1,31 @@ +import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; + +import { BrowserModule } from "@angular/platform-browser"; +import { AppComponent } from "./app.component"; +import { AngularComponentsModule } from "@abgov/angular-components"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { NgForOf, NgIf } from "@angular/common"; +import { AbgovAppComponent } from "./abgov-app"; +import { AppRoutingModule } from "./app-routing.module"; +import "@abgov/web-components"; + +@NgModule({ + declarations: [], + imports: [ + AppComponent, + AngularComponentsModule, + AbgovAppComponent, + BrowserModule, + AppRoutingModule, + FormsModule, + NgForOf, + NgIf, + NoopAnimationsModule, + ReactiveFormsModule, + ], + providers: [], + bootstrap: [AppComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +export class AppModule {} diff --git a/apps/prs/angular/src/assets/.gitkeep b/apps/prs/angular/src/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/prs/angular/src/favicon.ico b/apps/prs/angular/src/favicon.ico new file mode 100644 index 0000000000..317ebcb233 Binary files /dev/null and b/apps/prs/angular/src/favicon.ico differ diff --git a/apps/prs/angular/src/index.html b/apps/prs/angular/src/index.html new file mode 100644 index 0000000000..1b9821ebb2 --- /dev/null +++ b/apps/prs/angular/src/index.html @@ -0,0 +1,18 @@ + + + + + + angular + + + + + + + + + + + + diff --git a/apps/prs/angular/src/main.ts b/apps/prs/angular/src/main.ts new file mode 100644 index 0000000000..c8de31031e --- /dev/null +++ b/apps/prs/angular/src/main.ts @@ -0,0 +1,6 @@ +import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; +import { AppModule } from "./app/app.module"; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .catch((err) => console.error(err)); diff --git a/apps/prs/angular/src/styles.css b/apps/prs/angular/src/styles.css new file mode 100644 index 0000000000..963ef40b19 --- /dev/null +++ b/apps/prs/angular/src/styles.css @@ -0,0 +1,6 @@ +/* You can add global styles to this file, and also import other style files */ +@import "../../../../dist/libs/web-components/index.css"; + +:root { + --goa-space-fill: 32ch; +} diff --git a/apps/prs/angular/tsconfig.app.json b/apps/prs/angular/tsconfig.app.json new file mode 100644 index 0000000000..fff4a41d44 --- /dev/null +++ b/apps/prs/angular/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"], + "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/apps/prs/angular/tsconfig.editor.json b/apps/prs/angular/tsconfig.editor.json new file mode 100644 index 0000000000..4ee6393404 --- /dev/null +++ b/apps/prs/angular/tsconfig.editor.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "types": [] + } +} diff --git a/apps/prs/angular/tsconfig.json b/apps/prs/angular/tsconfig.json new file mode 100644 index 0000000000..b94f8837df --- /dev/null +++ b/apps/prs/angular/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.editor.json" + } + ], + "extends": "../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/apps/prs/react/.eslintrc.json b/apps/prs/react/.eslintrc.json new file mode 100644 index 0000000000..75b85077de --- /dev/null +++ b/apps/prs/react/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/prs/react/index.html b/apps/prs/react/index.html new file mode 100644 index 0000000000..2d9b4c6bd8 --- /dev/null +++ b/apps/prs/react/index.html @@ -0,0 +1,27 @@ + + + + + + PlaygroundReact + + + + + + + + + + +
+ + + + diff --git a/apps/prs/react/project.json b/apps/prs/react/project.json new file mode 100644 index 0000000000..5d029099c5 --- /dev/null +++ b/apps/prs/react/project.json @@ -0,0 +1,61 @@ +{ + "name": "react-prs", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/prs/react/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/apps/prs/react" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "react-prs:build" + }, + "configurations": { + "development": { + "buildTarget": "react-prs:build:development", + "hmr": true + }, + "production": { + "buildTarget": "react-prs:build:production", + "hmr": false + } + } + }, + "preview": { + "executor": "@nx/vite:preview-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "react-prs:build" + }, + "configurations": { + "development": { + "buildTarget": "react-prs:build:development" + }, + "production": { + "buildTarget": "react-prs:build:production" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + }, + "tags": [] +} diff --git a/apps/prs/react/public/favicon.ico b/apps/prs/react/public/favicon.ico new file mode 100644 index 0000000000..317ebcb233 Binary files /dev/null and b/apps/prs/react/public/favicon.ico differ diff --git a/apps/prs/react/src/app/all.tsx b/apps/prs/react/src/app/all.tsx new file mode 100644 index 0000000000..913f5c3222 --- /dev/null +++ b/apps/prs/react/src/app/all.tsx @@ -0,0 +1,4845 @@ +import React, { useState } from "react"; + +import { Outlet } from "react-router-dom"; +import { + GoabAppFooter, + GoabAppHeader, + GoabMicrositeHeader, + GoabOneColumnLayout, + GoabSideMenu, + GoabSideMenuGroup, + GoabPageBlock, + GoabFormItem, + GoabRadioGroup, + GoabRadioItem, + GoabButton, + GoabSpacer, + GoabDropdown, + GoabDropdownItem, + GoabInput, + GoabBlock, + GoabDatePicker, + GoabTooltip, + GoabIcon, + GoabBadge, + GoabText, + GoabModal, + GoabButtonGroup, + GoabTextarea, + GoabTabs, + GoabTab, + GoabDivider, + GoabIconButton, + GoabCircularProgress, + GoabContainer, + GoabDetails, + GoabHeroBanner, + GoabHeroBannerActions, + GoabNotification, + GoabAppFooterMetaSection, + GoabAppFooterNavSection, + GoabFileUploadInput, + GoabFileUploadCard, + GoabSideMenuHeading, + GoabAccordion, + GoabSkeleton, + GoabCheckbox, + GoabFormStep, + GoabFormStepper, + GoabPages, + GoabCallout, + GoabPopover, + GoabAppHeaderMenu, + GoabTable, + GoabTableSortHeader, + GoabGrid, + GoabLink, +} from "@abgov/react-components"; + +import { + GoabDropdownOnChangeDetail, + GoabInputOnChangeDetail, + GoabTextAreaOnChangeDetail, +} from "@abgov/ui-components-common"; + +function onChange(tabIndex: number): void { + console.log("Tab changed to ", tabIndex); +} + +export function AllComponents() { + // hooks + const [destructiveModalOpen, setDestructiveModalOpen] = useState(); + const [basicModalOpen, setBasicModalOpen] = useState(); + const [basicModal2Open, setBasicModal2Open] = useState(); + const [basicModal3Open, setBasicModal3Open] = useState(); + const [contentModalOpen, setContentModalOpen] = useState(); + const [contentModalScrollOpen, setContentModalScrollOpen] = useState(); + const [contentModal2Open, setContentModal2Open] = useState(); + const [NoHeaderModalOpen, setNoHeaderModalOpen] = useState(); + const [step, setStep] = useState(-1); + const [step2, setStep2] = useState(-1); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const noop = () => {}; + + function radio1(name: string, value: string) { + console.log("onChange", name, value); + } + + function radio2(name: string, value: string) { + console.log("onChange", name, value); + } + + function radio3(name: string, value: string) { + console.log("onChange", name, value); + } + + const popovertarget = ( + + Click me + + ); + const [value, setValue] = useState(""); + + function onChangeDropdown(detail: GoabDropdownOnChangeDetail) { + setValue(detail.value as string); + } + + function onChangeTextArea(detail: GoabTextAreaOnChangeDetail) { + console.log(detail.value); + } + + interface User { + firstName: string; + lastName: string; + age: number; + } + + const [users, setUsers] = useState([]); + const [open, setOpen] = useState(false); + const [openNoActions, setOpenNoActions] = useState(false); + const [position, setPosition] = useState("left"); + const [dateTaken, setDateTaken] = useState("today"); + const [hasActionsSlot, setActionsSlot] = useState("y"); + + function openDrawer() { + if (hasActionsSlot === "n") { + setOpenNoActions(true); + } else { + setOpen(true); + } + } + + const _users: User[] = [ + { + firstName: "Christian", + lastName: "Batz", + age: 18, + }, + { + firstName: "Brain", + lastName: "Wisozk", + age: 19, + }, + { + firstName: "Neha", + lastName: "Jones", + age: 23, + }, + { + firstName: "Tristin", + lastName: "Buckridge", + age: 31, + }, + ]; + React.useEffect(() => { + setUsers(_users); + }, []); + + function sortData(sortBy: string, sortDir: number) { + const _users = [...users]; + _users.sort((a: any, b: any) => { + return (a[sortBy] > b[sortBy] ? 1 : -1) * sortDir; + }); + setUsers(_users); + } + + const containeractions = ( + + + + Edit + + + ); + + const containeractionsinverse = ( + + + + Edit + + + ); + + return ( + + {/* Main page content here */} +
+ + + + onChange(detail.tab)}> + + + --- + + + {/* ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Components + + } + > + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Badge + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Button + + + + Primary + Secondary + Tertiary + + + + Primary + + Secondary + + + Tertiary + + + + + Primary + + Secondary + + + Tertiary + + + + + Compact primary + + Compact secondary + + + Compact tertiary + + + + + + Compact primary + + + Compact secondary + + + Compact tertiary + + + + + + Compact primary + + + Compact secondary + + + Compact tertiary + + + + + Destructive primary + + Destructive secondary + + + Destructive tertiary + + + + + + Destructive primary + + + Destructive secondary + + + Destructive tertiary + + + + + + Destructive primary + + + Destructive secondary + + + Destructive tertiary + + + + + + Compact destructive primary + + + Compact destructive secondary + + + Compact destructive tertiary + + + + + + Compact destructive primary + + + Compact destructive secondary + + + Compact destructive tertiary + + + + + + Compact destructive primary + + + Compact destructive secondary + + + Compact destructive tertiary + + + + Start + + + + Inverse (experimental) + + + + Regular primary + Regular secondary + Regular tertiary + + +
+ + Inverse primary + + Inverse secondary + + + Inverse tertiary + + +
+ + + Disabled + + + + Primary + + Secondary + + + Tertiary + + + + + + Primary + + + Secondary + + + Tertiary + + + + + + Primary + + + Secondary + + + Tertiary + + + + + + Cmpact primary + + + Compact secondary + + + Compact tertiary + + + + + + Compact primary + + + Compact secondary + + + Compact tertiary + + + + + + Compact primary + + + Compact secondary + + + Compact tertiary + + + + + + Destructive primary + + + Destructive secondary + + + Destructive tertiary + + + + + + Destructive primary + + + Destructive secondary + + + Destructive tertiary + + + + + + Destructive primary + + + Destructive secondary + + + Destructive tertiary + + + + + + Compact destructive primary + + + Compact destructive secondary + + + Compact destructive tertiary + + + + + + Compact destructive primary + + + Compact destructive secondary + + + Compact destructive tertiary + + + + + + Compact destructive primary + + + Compact destructive secondary + + + Compact destructive tertiary + + + + + + Disabled start + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Button group + + + + Default + Button + Group + + + + Compact + + Button + + + Group + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Checkbox + + + + + + + + Help text with a link. + + } + > + + + Help text with a link. + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Container + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Non-interactive with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Non-interactive with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Non-interactive filled + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Interactive with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Interactive with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Interactive filled + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Info with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Info with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Info filled + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Error with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Error with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Error filled + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Success with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Success with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Success filled + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Important with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Important with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Important filled + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Non-interactive, compact with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Non-interactive, compact with accent + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + + Non-interactive, compact + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam at + risus et magna interdum vestibulum in at ligula. + + + + + + + Button + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Date picker + + + + + + + + + + + + + + + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Detail + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vel + lacinia metus, sed sodales lectus. Aliquam sed volutpat velit. + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vel + lacinia metus, sed sodales lectus. Aliquam sed volutpat velit. + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Divider + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Dropdown + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + File upload + + + + + { + /* do nothing */ + }} + /> + + + + { + /** do nothing **/ + }} + /> + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Form item + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + /** do nothing **/ + }} + /> + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Form stepper + + + + setStep(e.step)}> + + + + + + +
Page 1 content
+
Page 2 content
+
Page 3 content
+
Page 4 content
+
+ + setStep2(e.step)}> + + + + + + + +
Page 1 content
+
Page 2 content
+
Page 3 content
+
Page 4 content
+
Page 5 content
+
+
+ + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Hero Banner + + + + + Resources are available to help Alberta entrepreneurs and small + businesses start, grow and succeed. + + Call to action + + + + + Resources are available to help Alberta entrepreneurs and small + businesses start, grow and succeed. + + Call to action + + + + + Resources are available to help Alberta entrepreneurs and small + businesses start, grow and succeed. + + Call to action + + + + + Resources are available to help Alberta entrepreneurs and small + businesses start, grow and succeed. + + Call to action + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Icon + + + + Tshirt sizing + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Number sizing + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inverted + +
+ + + + + + +
+ + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Icon button + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + +
+ + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Input + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Link + + + + Link with a leading icon + + Link with a trailing icon + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Microsite header + + + + + + + + + + + Feedback link + + + + + + + + + + Version number + + + + + + + + + + Feedback link and Version number + + + + + + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Modal + + + + setDestructiveModalOpen(true)} + > + Delete my application + + + + setDestructiveModalOpen(false)} + > + Cancel + + { + setDestructiveModalOpen(false); + }} + > + Delete application + + + } + > +

This action cannot be undone.

+
+ + setBasicModalOpen(true)}> + Open basic modal with close + + + setBasicModalOpen(false)} + > +

+ This is meant to be dismissed, the user can click outside of the modal + or click the close button in the top right corner. +

+
+ + setBasicModal2Open(true)}> + Open basic modal with actions + + + + setBasicModal2Open(false)} + > + Cancel + + { + setBasicModal2Open(false); + }} + > + Continue + + + } + > +

+ This is meant to make the user choose an option in order to continue. +

+
+ + setContentModalOpen(true)}> + Open modal with lots of content and actions + + + + setContentModalOpen(false)} + > + Cancel + + { + setContentModalOpen(false); + }} + > + Continue + + + } + > +

+ This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal + scroll.This is a lot of content that make the modal scroll. This is a + lot of content that make the modal scroll. This is a lot of content + that make the modal scroll. This is a lot of content that make the + modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. +

+ + ): void { + throw new Error("Function not implemented."); + }} + > + {" "} +
+ + setContentModal2Open(true)}> + Open modal with lots of content and close button + + + setContentModal2Open(false)} + > +

+ This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. +

+
+ + setBasicModal3Open(true)}> + Open basic modal with actions and close button + + + setBasicModal3Open(false)} + actions={ + + setBasicModal3Open(false)} + > + Cancel + + { + setBasicModal3Open(false); + }} + > + Continue + + + } + > +

+ The use can dismiss the modal by clicking outside of the modal, + clicking the close button, or choose an option to continue.{" "} +

+
+ + setNoHeaderModalOpen(true)}> + Open modal with no header + + + setNoHeaderModalOpen(false)} */ + actions={ + + setNoHeaderModalOpen(false)} + > + Cancel + + { + setNoHeaderModalOpen(false); + }} + > + Continue + + + } + > +

+ This is a modal with no header. Choose an option to continue. Lorem + ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse + facilisis quam ac massa commodo fringilla. Sed gravida elit urna, vel + rhoncus velit ullamcorper vitae. Phasellus ullamcorper enim et leo + dignissim, sed dignissim mi varius. +

+
+
+ + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Notification banner + + + + + Notification banner message + + + + Notification banner message that is really long and eventually it wraps + around the screen because it's so long that it needs to wrap around the + screen + + + + Notification banner message + + + + Notification banner message + + + + Notification banner message + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Pagination + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Popover + + + + +

This is a popover

+ It can be used for a number of different contexts. +
+
+ + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Progress indicator + + + + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Radio + + + + + radio1(e.name, e.value)} + > + + + + + + + + + radio2(e.name, e.value)} + > + + + + + + + + + radio2(e.name, e.value)} + > + +
+ + {" "} + {" "} +
+
+ + {" "} +
+ + {" "} + {" "} +
+
+
+
+ + + radio2(e.name, e.value)} + > + + + + + + + + + radio3(e.name, e.value)} + > + + Help text with a link. + + } + /> + + + + + + + radio3(e.name, e.value)} + > + + Help text with a link. + + } + /> + + + + + + + radio2(e.name, e.value)} + > + + + + + + + + + radio2(e.name, e.value)} + > + + + + + + + radio2(e.name, e.value)} + > + + + + + + + + + radio2(e.name, e.value)} + > + + + + + + +
+ + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Scroll bar + + + + setContentModalScrollOpen(true)} + > + Open modal with lots of content to see scroll bar + + + + setContentModalScrollOpen(false)} + > + Cancel + + { + setContentModalScrollOpen(false); + }} + > + Continue + + + } + > +

+ This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. This is a lot of content that make + the modal scroll. This is a lot of content that make the modal scroll. + This is a lot of content that make the modal scroll. This is a lot of + content that make the modal scroll. +

+
+
+ + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Side menu + + + + + + +
+ + + This is a side menu heading + + This is a side menu item + This is another side menu item + + + This is another side menu heading + + Side menu item + Side menu item + + } + > + Side menu heading + + + Foo + Bar + + + Foo + Bar + + Foo + Bar + + Foo + Bar + + + + +
+ + +
+
+ + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Table + + + + + + + Status + Text + Number + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + + + Status + Text + Number + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + sortData(e.sortBy, e.sortDir)}> + + + + + First name + + + + + Last name + + + + + Age + + + + + + {users.map((user) => ( + + {user.firstName} + {user.lastName} + {user.age} + + ))} + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Tabs + + + + + + Tab 1 content: Lorem ipsum dolor sit amet, consectetur adipiscing + elit, sed do eiusmod tempor incididunt ut labore et dolore magna + aliqua. + + + Tab 2 content: Lorem ipsum dolor sit amet, consectetur adipiscing + elit, sed do eiusmod tempor incididunt ut labore et dolore magna + aliqua. + + + Tab 3 content: Lorem ipsum dolor sit amet, consectetur adipiscing + elit, sed do eiusmod tempor incididunt ut labore et dolore magna + aliqua. + + + + + + + + + + No content + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Text area + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Component ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ */} + + + Tooltip + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* TAB ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */} + + + + + + + + + + + + + {/* TODO: add file cards on upload file. eslint-disable-next-line @typescript-eslint/no-empty-function */} + console.log(file)} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* TAB ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */} + + + + + + Status + Text + Number + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + + Text that runs really really really really really really really + really really really really really really really really really + really really really really really really really really really + really really really really really really really really really + really really long{" "} + + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + + + Status + Text + Number + Action + + + + + + + + + Text that runs really really really really really really really + really really really really really really really really really + really really really really really really really really really + really really really really really really really really really + really really long + + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + + Lorem ipsum + 1234567890 + + + Action + + + + + + + sortData(e.sortBy, e.sortDir)}> + + + First name + + + Last name + + + + + Age + + + + + + {users.map((user) => ( + + {user.firstName} + {user.lastName} + {user.age} + + ))} + + + + + + {/* TAB ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */} + + + + {" "} + Back link{" "} + + + {/* Apply max width to input, not form item for fixed width inputs. */} + + + Heading extra large as page h1 + + + + Heading large + + + + Heading medium + + + + Heading small + + + + Heading extra small + + + + Body large Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec + rutrum dignissim erat quis iaculis. + + + + Body medium text, lorem ipsum dolor sit amet, consectetur adipiscing elit. + Donec rutrum dignissim erat quis iaculis. + + + + Body small text, lorem ipsum dolor sit amet, consectetur adipiscing elit. + Donec rutrum dignissim erat quis iaculis. + + + + Body extra small text, lorem ipsum dolor sit amet, consectetur adipiscing + elit. Donec rutrum dignissim erat quis iaculis. + + + + Text component with margin top and bottom + + + + Text component with margin top + + + + Text component with margin bottom + + + + {/* TAB ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */} + + + + Image + + + + + + + + Text + + + + + + + + Title + + + + + + + + Text-small + + + + + + + + Avatar + + + + + + + + Header + + + + + + + + Paragraph + + + + + + + + Thumbnail + + + + + + + + Card + + + + + + + + Profile + + + + + + + +
+
+
+ +
+ + + Link 123we + Link 2 + Link 3 + Other thing + + + Link 123we + Link 2 + Link 3 + Other thing + + + Meta link + Meta link + Meta link + Meta link + + + + + + {" "} + + + + + Meta link + Meta link + Meta link + Meta link + + + + + + Link 1 + Link 2 + Link 3 + Other thing + + +
+
+ ); +} + +export default AllComponents; diff --git a/apps/prs/react/src/app/app.module.css b/apps/prs/react/src/app/app.module.css new file mode 100644 index 0000000000..7b88fbabf8 --- /dev/null +++ b/apps/prs/react/src/app/app.module.css @@ -0,0 +1 @@ +/* Your styles goes here. */ diff --git a/apps/prs/react/src/app/app.tsx b/apps/prs/react/src/app/app.tsx new file mode 100644 index 0000000000..26d7e15f19 --- /dev/null +++ b/apps/prs/react/src/app/app.tsx @@ -0,0 +1,32 @@ +import { Outlet } from "react-router-dom"; + +import { + GoabAppFooter, + GoabAppHeader, + GoabMicrositeHeader, + GoabOneColumnLayout, +} from "@abgov/react-components"; +import "@abgov/style"; + + +export function App() { + return ( + +
+ + + View All + Issues + +
+
+ +
+
+ +
+
+ ); +} + +export default App; diff --git a/apps/prs/react/src/assets/.gitkeep b/apps/prs/react/src/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/prs/react/src/main.tsx b/apps/prs/react/src/main.tsx new file mode 100644 index 0000000000..1d380c7664 --- /dev/null +++ b/apps/prs/react/src/main.tsx @@ -0,0 +1,26 @@ +import { StrictMode } from "react"; + +import * as ReactDOM from "react-dom/client"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import "@abgov/web-components"; +import App from "./app/app"; +import AllComponents from "./app/all"; + +const root = ReactDOM.createRoot( + document.getElementById("root") as HTMLElement, +); + +root.render( + + + + }> + + } /> + { /* add new routes here */ } + + + + + , +); diff --git a/apps/prs/react/src/routes/issues/.gitkeep b/apps/prs/react/src/routes/issues/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/prs/react/src/styles.css b/apps/prs/react/src/styles.css new file mode 100644 index 0000000000..90d4ee0072 --- /dev/null +++ b/apps/prs/react/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/apps/prs/react/tsconfig.app.json b/apps/prs/react/tsconfig.app.json new file mode 100644 index 0000000000..2a5ff5ebd5 --- /dev/null +++ b/apps/prs/react/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/apps/prs/react/tsconfig.json b/apps/prs/react/tsconfig.json new file mode 100644 index 0000000000..4467b39320 --- /dev/null +++ b/apps/prs/react/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["vite/client"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/apps/prs/react/vite.config.ts b/apps/prs/react/vite.config.ts new file mode 100644 index 0000000000..f77894c6f3 --- /dev/null +++ b/apps/prs/react/vite.config.ts @@ -0,0 +1,43 @@ +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../../node_modules/.vite/playground/react", + + server: { + port: 4201, + host: "0.0.0.0", + // Enable SPA fallback for client-side routing + historyApiFallback: true, + }, + + preview: { + port: 4300, + host: "localhost", + // Enable SPA fallback in preview mode too + historyApiFallback: true, + }, + + plugins: [react(), nxViteTsPaths()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + build: { + outDir: "../../../dist/apps/prs/react", + reportCompressedSize: true, + minify: false, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, + + define: { + "import.meta.vitest": undefined, + }, +}); diff --git a/apps/prs/web/.babelrc b/apps/prs/web/.babelrc new file mode 100644 index 0000000000..f2f3806745 --- /dev/null +++ b/apps/prs/web/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@nx/js/babel"] +} diff --git a/apps/prs/web/.eslintrc.json b/apps/prs/web/.eslintrc.json new file mode 100644 index 0000000000..3456be9b90 --- /dev/null +++ b/apps/prs/web/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/prs/web/.swcrc b/apps/prs/web/.swcrc new file mode 100644 index 0000000000..a2d5b04f47 --- /dev/null +++ b/apps/prs/web/.swcrc @@ -0,0 +1,8 @@ +{ + "jsc": { + "parser": { + "syntax": "typescript" + }, + "target": "es2016" + } +} diff --git a/apps/prs/web/index.html b/apps/prs/web/index.html new file mode 100644 index 0000000000..34d5db9f03 --- /dev/null +++ b/apps/prs/web/index.html @@ -0,0 +1,21 @@ + + + + + + PlaygroundWeb + + + + + + + + + + +
+ + + + diff --git a/apps/prs/web/project.json b/apps/prs/web/project.json new file mode 100644 index 0000000000..3e590fd061 --- /dev/null +++ b/apps/prs/web/project.json @@ -0,0 +1,68 @@ +{ + "name": "web-prs", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/prs/web/src", + "tags": [], + "implicitDependencies": [ + "web-components" + ], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": [ + "{options.outputPath}" + ], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/apps/prs/web" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "web-prs:build" + }, + "configurations": { + "development": { + "buildTarget": "web-prs:build:development", + "hmr": true + }, + "production": { + "buildTarget": "web-prs:build:production", + "hmr": false + } + } + }, + "preview": { + "executor": "@nx/vite:preview-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "web-prs:build" + }, + "configurations": { + "development": { + "buildTarget": "web-prs:build:development" + }, + "production": { + "buildTarget": "web-prs:build:production" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ] + } + } +} diff --git a/apps/prs/web/public/favicon.ico b/apps/prs/web/public/favicon.ico new file mode 100644 index 0000000000..317ebcb233 Binary files /dev/null and b/apps/prs/web/public/favicon.ico differ diff --git a/apps/prs/web/src/app/App.svelte b/apps/prs/web/src/app/App.svelte new file mode 100644 index 0000000000..4f92261645 --- /dev/null +++ b/apps/prs/web/src/app/App.svelte @@ -0,0 +1,13 @@ + + Goab Component Playground + + + + + + + diff --git a/apps/prs/web/src/assets/.gitkeep b/apps/prs/web/src/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/prs/web/src/main.js b/apps/prs/web/src/main.js new file mode 100644 index 0000000000..38af7076f3 --- /dev/null +++ b/apps/prs/web/src/main.js @@ -0,0 +1,11 @@ +import "@abgov/web-components"; +import App from "./app/App.svelte"; + +let app; +const target = document.getElementById("app"); +if (target) { + app = new App({ target }); +} else { + console.error("Target element not found"); +} +export default app; diff --git a/apps/prs/web/src/routes/2333.svelte b/apps/prs/web/src/routes/2333.svelte new file mode 100644 index 0000000000..10806a83e0 --- /dev/null +++ b/apps/prs/web/src/routes/2333.svelte @@ -0,0 +1,19 @@ + + +
+

Route 2333

+

This is the content for the /2333 route.

+
+ + diff --git a/apps/prs/web/src/styles.css b/apps/prs/web/src/styles.css new file mode 100644 index 0000000000..90d4ee0072 --- /dev/null +++ b/apps/prs/web/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/apps/prs/web/src/svelte-shim.d.ts b/apps/prs/web/src/svelte-shim.d.ts new file mode 100644 index 0000000000..393ff5ef70 --- /dev/null +++ b/apps/prs/web/src/svelte-shim.d.ts @@ -0,0 +1,5 @@ +declare module '*.svelte' { + import type { ComponentType } from 'svelte'; + const component: ComponentType; + export default component; +} diff --git a/apps/prs/web/svelte.config.mjs b/apps/prs/web/svelte.config.mjs new file mode 100644 index 0000000000..3bce8eaa6c --- /dev/null +++ b/apps/prs/web/svelte.config.mjs @@ -0,0 +1,7 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +}; diff --git a/apps/prs/web/tsconfig.app.json b/apps/prs/web/tsconfig.app.json new file mode 100644 index 0000000000..5a6d3e61df --- /dev/null +++ b/apps/prs/web/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"], + "exclude": ["vite.config.mts", "svelte.config.mjs", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/apps/prs/web/tsconfig.json b/apps/prs/web/tsconfig.json new file mode 100644 index 0000000000..715e5cd65f --- /dev/null +++ b/apps/prs/web/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src"], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/apps/prs/web/vite.config.mts b/apps/prs/web/vite.config.mts new file mode 100644 index 0000000000..8d35eda1ad --- /dev/null +++ b/apps/prs/web/vite.config.mts @@ -0,0 +1,38 @@ +/// +import { defineConfig } from "vite"; +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../node_modules/.vite/apps/prs-web", + + server: { + port: 4202, + host: "0.0.0.0", + // Enable SPA fallback for client-side routing + historyApiFallback: true, + }, + + preview: { + port: 4300, + host: "localhost", + // Enable SPA fallback in preview mode too + historyApiFallback: true, + }, + + plugins: [nxViteTsPaths(), svelte()], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + build: { + outDir: "../../dist/apps/prs-web", + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, +}); diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000..6915301551 --- /dev/null +++ b/flake.lock @@ -0,0 +1,126 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1761672384, + "narHash": "sha256-o9KF3DJL7g7iYMZq9SWgfS1BFlNbsm6xplRjVlOCkXI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "08dacfca559e1d7da38f3cf05f1f45ee9bfd213c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 0, + "narHash": "sha256-u+rxA79a0lyhG+u+oPBRtTDtzz8kvkc9a6SWSt9ekVc=", + "path": "/nix/store/0283cbhm47kd3lr9zmc5fvdrx9qkav8s-source", + "type": "path" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "playwright": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1760833992, + "narHash": "sha256-CiVBf+Com8mwDexMVw6s4BIT1J1In/UNHvaqiZwSfIs=", + "owner": "pietdevries94", + "repo": "playwright-web-flake", + "rev": "d3996ee82c6bcdc4c9535b94068abaa2744a7411", + "type": "github" + }, + "original": { + "owner": "pietdevries94", + "repo": "playwright-web-flake", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "playwright": "playwright" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000..b9aaf9fd8b --- /dev/null +++ b/flake.nix @@ -0,0 +1,32 @@ +{ + description = "GoA ui-component dev environment"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.playwright.url = "github:pietdevries94/playwright-web-flake"; + + outputs = { self, flake-utils, nixpkgs, playwright }: + flake-utils.lib.eachDefaultSystem (system: + let + overlay = final: prev: { + inherit (playwright.packages.${system}) playwright-test playwright-driver; + }; + pkgs = import nixpkgs { + inherit system; + overlays = [ overlay ]; + }; + in + { + devShells = { + default = pkgs.mkShell { + packages = [ + pkgs.nodejs_24 + pkgs.playwright-test + ]; + shellHook = '' + export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + export PLAYWRIGHT_BROWSERS_PATH="${pkgs.playwright-driver.browsers}" + ''; + }; + }; + }); +} diff --git a/libs/angular-components/src/lib/components/badge/badge.spec.ts b/libs/angular-components/src/lib/components/badge/badge.spec.ts index cfaa3e1fc2..9862bbaa19 100644 --- a/libs/angular-components/src/lib/components/badge/badge.spec.ts +++ b/libs/angular-components/src/lib/components/badge/badge.spec.ts @@ -33,16 +33,28 @@ class TestBadgeComponent { mr?: Spacing; } +@Component({ + standalone: true, + imports: [GoabBadge], + template: ` `, +}) +class TestBadgeNoIconComponent { + type?: GoabBadgeType; + content?: string; +} + describe("GoABBadge", () => { let fixture: ComponentFixture; let component: TestBadgeComponent; beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ - imports: [GoabBadge, TestBadgeComponent], + imports: [GoabBadge, 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"; @@ -58,9 +70,6 @@ describe("GoABBadge", () => { fixture.detectChanges(); tick(); fixture.detectChanges(); - })); - - it("should render and set the props correctly", () => { const badgeElement = fixture.debugElement.query(By.css("goa-badge")).nativeElement; expect(badgeElement.getAttribute("type")).toBe("information"); expect(badgeElement.getAttribute("content")).toBe("Information"); @@ -71,5 +80,32 @@ describe("GoABBadge", () => { 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/lib/components/badge/badge.ts b/libs/angular-components/src/lib/components/badge/badge.ts index b3aaf8e1cb..b52bbb3e4a 100644 --- a/libs/angular-components/src/lib/components/badge/badge.ts +++ b/libs/angular-components/src/lib/components/badge/badge.ts @@ -17,7 +17,7 @@ import { GoabBaseComponent } from "../base.component"; ` + * }) + * 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. @@ -40,10 +63,11 @@ export abstract class GoabBaseComponent { * * ## 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. + * - `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. @@ -87,12 +111,42 @@ export abstract class GoabControlValueAccessor } } + /** + * 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); + } } /** diff --git a/libs/angular-components/src/lib/components/checkbox-list/checkbox-list.ts b/libs/angular-components/src/lib/components/checkbox-list/checkbox-list.ts index db052a4dbe..c716ae827c 100644 --- a/libs/angular-components/src/lib/components/checkbox-list/checkbox-list.ts +++ b/libs/angular-components/src/lib/components/checkbox-list/checkbox-list.ts @@ -8,6 +8,7 @@ import { forwardRef, OnInit, ChangeDetectorRef, + Renderer2, } from "@angular/core"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { CommonModule } from "@angular/common"; @@ -51,8 +52,11 @@ export class GoabCheckboxList extends GoabControlValueAccessor implements OnInit // Override value to handle string arrays consistently @Input() override value?: string[]; - constructor(private cdr: ChangeDetectorRef) { - super(); + constructor( + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); } ngOnInit(): void { diff --git a/libs/angular-components/src/lib/components/checkbox/checkbox.spec.ts b/libs/angular-components/src/lib/components/checkbox/checkbox.spec.ts index fc7d6a82e0..35e21f225c 100644 --- a/libs/angular-components/src/lib/components/checkbox/checkbox.spec.ts +++ b/libs/angular-components/src/lib/components/checkbox/checkbox.spec.ts @@ -93,7 +93,7 @@ describe("GoabCheckbox", () => { expect(checkboxElement.getAttribute("maxwidth")).toBe("480px"); }); - it("should handle onChange event", fakeAsync(() => { + it("should handle onChange event", async () => { const onChange = jest.spyOn(component, "onChange"); const checkboxElement = fixture.debugElement.query( @@ -108,7 +108,50 @@ describe("GoabCheckbox", () => { ); expect(onChange).toHaveBeenCalled(); - })); + }); + + describe("writeValue", () => { + it("should set checked attribute to true when value is truthy", () => { + const checkboxComponent = fixture.debugElement.query(By.css("goab-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("goab-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("goab-checkbox")).componentInstance; + + checkboxComponent.writeValue(true); + expect(checkboxComponent.value).toBe(true); + + checkboxComponent.writeValue(null); + expect(checkboxComponent.value).toBe(null); + }); + }); }); @Component({ @@ -136,7 +179,7 @@ describe("Checkbox with description slot", () => { it("should render with slot description", fakeAsync(() => { TestBed.configureTestingModule({ - imports: [GoabCheckbox, ReactiveFormsModule, TestCheckboxWithDescriptionSlotComponent], + imports: [TestCheckboxWithDescriptionSlotComponent, GoabCheckbox, ReactiveFormsModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); @@ -155,7 +198,7 @@ describe("Checkbox with description slot", () => { @Component({ standalone: true, - imports: [GoabCheckbox, ReactiveFormsModule], + imports: [GoabCheckbox], template: ` { beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ - imports: [GoabCheckbox, ReactiveFormsModule, TestCheckboxWithRevealSlotComponent], + imports: [TestCheckboxWithRevealSlotComponent, GoabCheckbox, ReactiveFormsModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); diff --git a/libs/angular-components/src/lib/components/checkbox/checkbox.ts b/libs/angular-components/src/lib/components/checkbox/checkbox.ts index 47dcd083fd..4dc3ba957c 100644 --- a/libs/angular-components/src/lib/components/checkbox/checkbox.ts +++ b/libs/angular-components/src/lib/components/checkbox/checkbox.ts @@ -10,6 +10,7 @@ import { booleanAttribute, OnInit, ChangeDetectorRef, + Renderer2, } from "@angular/core"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { NgTemplateOutlet, CommonModule } from "@angular/common"; @@ -19,34 +20,35 @@ import { GoabControlValueAccessor } from "../base.component"; standalone: true, selector: "goab-checkbox", template: ` - -
- -
-
- -
-
`, + #goaComponentRef + *ngIf="isReady" + [attr.name]="name" + [checked]="checked" + [disabled]="disabled" + [attr.indeterminate]="indeterminate ? 'true' : undefined" + [attr.error]="error" + [attr.text]="text" + [value]="value" + [attr.testid]="testId" + [attr.arialabel]="ariaLabel" + [attr.description]="getDescriptionAsString()" + [attr.revealarialabel]="revealArialLabel" + [id]="id" + [attr.maxwidth]="maxWidth" + [attr.mt]="mt" + [attr.mb]="mb" + [attr.ml]="ml" + [attr.mr]="mr" + (_change)="_onChange($event)" + > + +
+ +
+
+ +
+ `, schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [ { @@ -60,8 +62,11 @@ import { GoabControlValueAccessor } from "../base.component"; export class GoabCheckbox extends GoabControlValueAccessor implements OnInit { isReady = false; - constructor(private cdr: ChangeDetectorRef) { - super(); + constructor( + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); } ngOnInit(): void { @@ -78,7 +83,7 @@ export class GoabCheckbox extends GoabControlValueAccessor implements OnInit { @Input({ transform: booleanAttribute }) indeterminate?: boolean; @Input() text?: string; // ** NOTE: can we just use the base component for this? - @Input() override value?: string | number | boolean; + @Input() override value?: string | number | boolean | null; @Input() ariaLabel?: string; @Input() description!: string | TemplateRef; @Input() reveal?: TemplateRef; @@ -104,4 +109,15 @@ export class GoabCheckbox extends GoabControlValueAccessor implements OnInit { 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/lib/components/date-picker/date-picker.ts b/libs/angular-components/src/lib/components/date-picker/date-picker.ts index dc9872fafa..2f5dc24f86 100644 --- a/libs/angular-components/src/lib/components/date-picker/date-picker.ts +++ b/libs/angular-components/src/lib/components/date-picker/date-picker.ts @@ -1,4 +1,7 @@ -import { GoabDatePickerInputType, GoabDatePickerOnChangeDetail } from "@abgov/ui-components-common"; +import { + GoabDatePickerInputType, + GoabDatePickerOnChangeDetail, +} from "@abgov/ui-components-common"; import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -10,6 +13,7 @@ import { HostListener, OnInit, ChangeDetectorRef, + Renderer2, } from "@angular/core"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { CommonModule } from "@angular/common"; @@ -20,6 +24,7 @@ import { GoabControlValueAccessor } from "../base.component"; selector: "goab-date-picker", imports: [CommonModule], template: ` (); @@ -78,8 +84,12 @@ export class GoabDatePicker extends GoabControlValueAccessor implements OnInit { this.fcChange?.(detail.value); } - constructor(protected elementRef: ElementRef, private cdr: ChangeDetectorRef) { - super(); + constructor( + protected elementRef: ElementRef, + private cdr: ChangeDetectorRef, + renderer: Renderer2, + ) { + super(renderer); } ngOnInit(): void { @@ -89,6 +99,12 @@ export class GoabDatePicker extends GoabControlValueAccessor implements OnInit { 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) { @@ -104,13 +120,13 @@ export class GoabDatePicker extends GoabControlValueAccessor implements OnInit { override writeValue(value: Date | null): void { this.value = value; - const datePickerEl = this.elementRef?.nativeElement?.querySelector("goa-date-picker"); - + const datePickerEl = this.goaComponentRef?.nativeElement as HTMLElement | undefined; if (datePickerEl) { if (!value) { - datePickerEl.setAttribute("value", ""); + this.renderer.setAttribute(datePickerEl, "value", ""); } else { - datePickerEl.setAttribute( + this.renderer.setAttribute( + datePickerEl, "value", value instanceof Date ? value.toISOString() : value, ); diff --git a/libs/angular-components/src/lib/components/dropdown/dropdown.spec.ts b/libs/angular-components/src/lib/components/dropdown/dropdown.spec.ts index e47f7edebe..c9e58d824c 100644 --- a/libs/angular-components/src/lib/components/dropdown/dropdown.spec.ts +++ b/libs/angular-components/src/lib/components/dropdown/dropdown.spec.ts @@ -9,7 +9,8 @@ import { fireEvent } from "@testing-library/dom"; @Component({ standalone: true, - imports: [GoabDropdown, GoabDropdownItem, ReactiveFormsModule], + imports: [GoabDropdown, GoabDropdownItem], + schemas: [CUSTOM_ELEMENTS_SCHEMA], template: ` { beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ - imports: [GoabDropdown, GoabDropdownItem, ReactiveFormsModule, TestDropdownComponent], + imports: [TestDropdownComponent, GoabDropdown, GoabDropdownItem, ReactiveFormsModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); @@ -140,6 +141,8 @@ describe("GoABDropdown", () => { 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(); @@ -152,4 +155,79 @@ describe("GoABDropdown", () => { ); expect(onChangeMock).toHaveBeenCalled(); })); -}); + + describe("writeValue", () => { + it("should set value attribute when writeValue is called with a value", () => { + const dropdownComponent = fixture.debugElement.query(By.css("goab-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("goab-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("goab-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("goab-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("goab-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); + }); + }); +}); \ No newline at end of file diff --git a/libs/angular-components/src/lib/components/dropdown/dropdown.ts b/libs/angular-components/src/lib/components/dropdown/dropdown.ts index eb5ab04cca..5fa3dbc93e 100644 --- a/libs/angular-components/src/lib/components/dropdown/dropdown.ts +++ b/libs/angular-components/src/lib/components/dropdown/dropdown.ts @@ -9,6 +9,7 @@ import { forwardRef, OnInit, ChangeDetectorRef, + Renderer2, } from "@angular/core"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { CommonModule } from "@angular/common"; @@ -21,6 +22,7 @@ import { GoabControlValueAccessor } from "../base.component"; imports: [CommonModule], template: ` ).detail; + // Keep local value in sync with emitted detail + this.value = detail.value || null; this.onChange.emit(detail); this.markAsTouched(); this.fcChange?.(detail.value || ""); } -} \ No newline at end of file +} diff --git a/libs/angular-components/src/lib/components/input/input.spec.ts b/libs/angular-components/src/lib/components/input/input.spec.ts index 27fd76e318..8069b13919 100644 --- a/libs/angular-components/src/lib/components/input/input.spec.ts +++ b/libs/angular-components/src/lib/components/input/input.spec.ts @@ -266,6 +266,52 @@ describe("GoABInput", () => { 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("goab-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("goab-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("goab-input")).componentInstance; + + inputComponent.writeValue("updated"); + expect(inputComponent.value).toBe("updated"); + + inputComponent.writeValue(null); + expect(inputComponent.value).toBe(null); + }); + }); }); @Component({ diff --git a/libs/angular-components/src/lib/components/input/input.ts b/libs/angular-components/src/lib/components/input/input.ts index f1d16292ec..41fa0b8b37 100644 --- a/libs/angular-components/src/lib/components/input/input.ts +++ b/libs/angular-components/src/lib/components/input/input.ts @@ -19,6 +19,7 @@ import { numberAttribute, TemplateRef, ChangeDetectorRef, + Renderer2, } from "@angular/core"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { GoabControlValueAccessor } from "../base.component"; @@ -34,6 +35,7 @@ export interface IgnoreMe { imports: [NgIf, NgTemplateOutlet, CommonModule], template: ` { beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ - imports: [GoabRadioGroup, GoabRadioItem, TestRadioGroupComponent], + imports: [TestRadioGroupComponent, GoabRadioGroup, GoabRadioItem], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); @@ -136,14 +137,12 @@ describe("GoABRadioGroup", () => { }); }); - it("should render description", fakeAsync(() => { + 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(); - tick(); - fixture.detectChanges(); const radioGroup = fixture.nativeElement.querySelector("goa-radio-group"); expect(radioGroup).toBeTruthy(); @@ -157,16 +156,62 @@ describe("GoABRadioGroup", () => { // 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 radioGroup = fixture.nativeElement.querySelector("goa-radio-group"); fireEvent(radioGroup, new CustomEvent("_change", { - detail: { "name": component.name, value: component.options[0].value } + detail: {"name": component.name, value: component.options[0].value} })); - expect(onChange).toBeCalledWith({ name: component.name, value: component.options[0].value }); - }) + expect(onChange).toBeCalledWith({name: component.name, value: component.options[0].value}); + }); + + describe("writeValue", () => { + it("should set value attribute when writeValue is called", () => { + const radioGroupComponent = fixture.debugElement.query(By.css("goab-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("goab-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("goab-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/lib/components/radio-group/radio-group.ts b/libs/angular-components/src/lib/components/radio-group/radio-group.ts index a2284c6128..56e614363d 100644 --- a/libs/angular-components/src/lib/components/radio-group/radio-group.ts +++ b/libs/angular-components/src/lib/components/radio-group/radio-group.ts @@ -11,6 +11,7 @@ import { forwardRef, OnInit, ChangeDetectorRef, + Renderer2, } from "@angular/core"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { CommonModule } from "@angular/common"; @@ -21,6 +22,7 @@ import { GoabControlValueAccessor } from "../base.component"; selector: "goab-radio-group", template: ` { expect(onBlur).toBeCalledTimes(1); }); + + describe("writeValue", () => { + it("should set value attribute when writeValue is called", () => { + const textareaComponent = fixture.debugElement.query(By.css("goab-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("goab-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("goab-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/lib/components/textarea/textarea.ts b/libs/angular-components/src/lib/components/textarea/textarea.ts index 453371b511..8887d28550 100644 --- a/libs/angular-components/src/lib/components/textarea/textarea.ts +++ b/libs/angular-components/src/lib/components/textarea/textarea.ts @@ -15,6 +15,7 @@ import { numberAttribute, OnInit, ChangeDetectorRef, + Renderer2, } from "@angular/core"; import { NG_VALUE_ACCESSOR } from "@angular/forms"; import { CommonModule } from "@angular/common"; @@ -26,6 +27,7 @@ import { GoabControlValueAccessor } from "../base.component"; imports: [CommonModule], template: ` = GoabInputOnChangeDetail; export type GoabMenuButtonOnActionDetail = { action: string; -} +}; export type GoabInputAutoCapitalize = | "on" @@ -70,7 +70,11 @@ export type GoabDropdownOnChangeDetail = { export type GoabDatePickerOnChangeDetail = { name?: string; - value: string | Date | undefined; + valueStr: string; + /** + * @deprecated Use `valueStr` instead + */ + value: Date; }; export type GoabDatePickerInputType = "calendar" | "input"; @@ -1158,7 +1162,6 @@ export type GoabPublicFormStatus = "initializing" | "complete"; export type GoabPublicFormPageStep = "step" | "summary" | "multistep"; export type GoabPublicFormPageButtonVisibility = "visible" | "hidden"; - // Public form Task export type GoabPublicFormTaskStatus = "completed" | "not-started" | "cannot-start"; diff --git a/libs/react-components/specs/calendar.browser.spec.tsx b/libs/react-components/specs/calendar.browser.spec.tsx index 5a54fe9124..a85d7b075c 100644 --- a/libs/react-components/specs/calendar.browser.spec.tsx +++ b/libs/react-components/specs/calendar.browser.spec.tsx @@ -2,30 +2,712 @@ import { render } from "vitest-browser-react"; import { GoabCalendar } from "../src"; import { expect, describe, it, vi } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { format, addDays, addMonths, addYears } from "date-fns"; describe("Calendar", () => { const noop = () => { // noop }; + it("renders", async () => { + const handleChange = vi.fn(); + + const Component = () => { + return ; + }; + + const result = render(); + + await vi.waitFor(() => { + const calendar = result.getByTestId("cal"); + expect(calendar.element()).toBeTruthy(); + }); + }); + + it("renders with a value", async () => { + const value = "2024-03-15"; + const handleChange = vi.fn(); + + const Component = () => { + return ; + }; + + const result = render(); + const selectedDate = result.getByTestId("2024-03-15"); + + await vi.waitFor(() => { + expect(selectedDate.element()).toBeTruthy(); + expect(selectedDate.element().classList.contains("selected")).toBe(true); + }); + }); + + it("dispatches change event when clicked", async () => { + const handleChange = vi.fn(); + const today = new Date(); + const todayStr = format(today, "yyyy-MM-dd"); + + const Component = () => { + return ; + }; + + const result = render(); + const dateButton = result.getByTestId(todayStr); + + await dateButton.click(); + + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "birthdate", + value: expect.any(String), + }); + }); + }); + + it("respects min date constraint", async () => { + const handleChange = vi.fn(); + const today = new Date(); + const minDate = format(today, "yyyy-MM-dd"); + const pastDate = format(addDays(today, -5), "yyyy-MM-dd"); + + const Component = () => { + return ; + }; + + const result = render(); + const pastDateButton = result.getByTestId(pastDate); + + await vi.waitFor(() => { + expect(pastDateButton.element().classList.contains("disabled")).toBe(true); + }); + + await pastDateButton.click(); + + // Should not trigger change for disabled date + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it("respects max date constraint", async () => { + const handleChange = vi.fn(); + const testDate = new Date(2024, 2, 15); // March 15, 2024 + const maxDate = format(testDate, "yyyy-MM-dd"); + const futureDate = format(addDays(testDate, 5), "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + + // Wait for component to render + await vi.waitFor(() => { + expect(result.getByTestId("cal-test").element()).toBeTruthy(); + }); + + const futureDateButton = result.getByTestId(futureDate); + + await vi.waitFor(() => { + expect(futureDateButton.element().classList.contains("disabled")).toBe(true); + }); + + await futureDateButton.click(); + + // Should not trigger change for disabled date + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(handleChange).not.toHaveBeenCalled(); + }); + + describe("Month and Year selection", () => { + it("changes the month when month dropdown is changed", async () => { + const handleChange = vi.fn(); + + const Component = () => { + return ( +
+ +
+ ); + }; + + const result = render(); + + const monthsDropdown = result.getByTestId("months"); + const june = result.getByTestId("dropdown-item-6"); + const firstDayOfJune = result.getByTestId("2024-06-01"); + + // select June + await monthsDropdown.click(); + await june.click(); + + // Wait for dropdown to be interactive + await vi.waitFor(() => { + expect(firstDayOfJune).toBeVisible(); + }); + }); + + it("changes the year when year dropdown is changed", async () => { + const handleChange = vi.fn(); + + const Component = () => { + return ( +
+ +
+ ); + }; + + const result = render(); + const yearsDropdown = result.getByTestId("years"); + const nextYear = result.getByTestId("dropdown-item-2025"); + const firstDay = result.getByTestId("2025-03-01"); + + // select June + await yearsDropdown.click(); + await nextYear.click(); + + // Wait for dropdown to be interactive + await vi.waitFor(() => { + expect(firstDay).toBeVisible(); + }); + }); + }); + + describe("Keyboard Navigation", () => { + it("navigates to previous day with ArrowLeft and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initialDate = result.getByTestId(startDateStr); + + await initialDate.click(); + await userEvent.keyboard("{ArrowLeft}"); + await userEvent.keyboard("{Enter}"); + + const prevDate = format(addDays(startDate, -1), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(prevDate), + }); + }); + }); + + it("navigates to next day with ArrowRight and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{ArrowRight}"); + await userEvent.keyboard("{Enter}"); + + const nextDate = format(addDays(startDate, 1), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(nextDate), + }); + }); + }); + + it("navigates to previous week with ArrowUp and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{ArrowUp}"); + await userEvent.keyboard("{Enter}"); + + const prevWeekDate = format(addDays(startDate, -7), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(prevWeekDate), + }); + }); + }); + + it("navigates to next week with ArrowDown and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{ArrowDown}"); + await userEvent.keyboard("{Enter}"); + + const nextWeekDate = format(addDays(startDate, 7), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(nextWeekDate), + }); + }); + }); + + it("navigates to first day of month with Home and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{Home}"); + await userEvent.keyboard("{Enter}"); + + const firstDay = format(new Date(2024, 2, 1), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(firstDay), + }); + }); + }); + + it("navigates to last day of month with End and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{End}"); + await userEvent.keyboard("{Enter}"); + + const lastDay = format(new Date(2024, 2, 31), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(lastDay), + }); + }); + }); + + it("navigates to previous month with PageUp and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{PageUp}"); + await userEvent.keyboard("{Enter}"); + + const prevMonthDate = format(addMonths(startDate, -1), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(prevMonthDate), + }); + }); + }); + + it("navigates to next month with PageDown and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{PageDown}"); + await userEvent.keyboard("{Enter}"); + + const nextMonthDate = format(addMonths(startDate, 1), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(nextMonthDate), + }); + }); + }); + + it("navigates to previous year with Shift+PageUp and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{Shift>}{PageUp}"); + await userEvent.keyboard("{Enter}"); + + const prevYearDate = format(addYears(startDate, -1), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(prevYearDate), + }); + }); + }); + + it("navigates to next year with Shift+PageDown and selects with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{Shift>}{PageDown}"); + await userEvent.keyboard("{Enter}"); + + const nextYearDate = format(addYears(startDate, 1), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(nextYearDate), + }); + }); + }); + + it("selects the focused date with Enter", async () => { + const handleChange = vi.fn(); + const startDate = new Date(2024, 2, 15); // March 15, 2024 + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{ArrowRight}"); // Move to next day + await userEvent.keyboard("{Enter}"); // Select it + + const expectedDate = format(addDays(startDate, 1), "yyyy-MM-dd"); + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(expectedDate), + }); + }); + }); + + it("does not navigate beyond min date constraint", async () => { + const handleChange = vi.fn(); + const minDate = new Date(2024, 2, 10); // March 10, 2024 + const startDate = new Date(2024, 2, 11); // March 11, 2024 + const minDateStr = format(minDate, "yyyy-MM-dd"); + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{ArrowLeft}"); // Try to move before min + await userEvent.keyboard("{Enter}"); // Try to select + + // Should still select March 11 (start date), not March 10 + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(startDateStr), + }); + }); + }); + + it("does not navigate beyond max date constraint", async () => { + const handleChange = vi.fn(); + const maxDate = new Date(2024, 2, 20); // March 20, 2024 + const startDate = new Date(2024, 2, 19); // March 19, 2024 + const maxDateStr = format(maxDate, "yyyy-MM-dd"); + const startDateStr = format(startDate, "yyyy-MM-dd"); + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const initDate = result.getByTestId(startDateStr); + + await initDate.click(); + await userEvent.keyboard("{ArrowRight}"); // Try to move after max + await userEvent.keyboard("{Enter}"); // Try to select + + // Should still select March 19 (start date), not March 20 + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: expect.stringContaining(startDateStr), + }); + }); + }); + }); + + describe("Visual states", () => { + it("highlights today's date", async () => { + const handleChange = vi.fn(); + const today = new Date(); + const todayStr = format(today, "yyyy-MM-dd"); + + const Component = () => { + return ; + }; + + const result = render(); + const todayButton = result.getByTestId(todayStr); + + await vi.waitFor(() => { + expect(todayButton.element().classList.contains("today")).toBe(true); + }); + }); + + it("highlights the selected date", async () => { + const handleChange = vi.fn(); + const selectedDate = "2024-03-15"; + + const Component = () => { + return ( + + ); + }; + + const result = render(); + const selectedButton = result.getByTestId(selectedDate); + + await vi.waitFor(() => { + expect(selectedButton.element().classList.contains("selected")).toBe(true); + }); + }); + + it("displays days from previous and next months", async () => { + const handleChange = vi.fn(); + const testDate = "2024-03-15"; // March 2024 + + const Component = () => { + return ( + + ); + }; + + const result = render(); + + // March 1, 2024 is a Friday, so we should see days from previous month + // Last days of February 2024 + const lastDayOfFeb = result.getByTestId("2024-02-29"); // Leap year + + await vi.waitFor(() => { + expect(lastDayOfFeb.element()).toBeTruthy(); + expect(lastDayOfFeb.element().classList.contains("other-month")).toBe(true); + }); + }); + }); + + describe("Edge cases", () => { + it("handles leap year correctly", async () => { + const handleChange = vi.fn(); + const leapDay = "2024-02-29"; + + const Component = () => { + return ; + }; + + const result = render(); + const leapDayButton = result.getByTestId(leapDay); + + await vi.waitFor(() => { + expect(leapDayButton.element()).toBeTruthy(); + expect(leapDayButton.element().classList.contains("selected")).toBe(true); + }); + }); + + it("handles month transitions when selecting dates from other months", async () => { + const handleChange = vi.fn(); + const testDate = "2024-03-01"; // March 1, 2024 (Friday) + + const Component = () => { + return ( + + ); + }; + + const result = render(); + + // Click on a day from February (displayed at the beginning of March calendar) + const febDay = result.getByTestId("2024-02-29"); + await febDay.click(); + + await vi.waitFor(() => { + expect(handleChange).toHaveBeenCalledWith({ + name: "", + value: "2024-02-29", + }); + }); + }); + }); + describe("Bug fixes", () => { describe("3156", () => { it("should render all months", async () => { - // The calendar contained falsey values const Component = () => { - return ( - - ); + return ; }; const result = render(); - const falseyOption = result.getByTestId("dropdown-item-0"); + const falseyOption = result.getByTestId("dropdown-item-1"); await vi.waitFor(() => { expect(falseyOption.element()).toBeTruthy(); - }) - }) - }) - }) + }); + }); + }); + }); }); diff --git a/libs/react-components/specs/datepicker.browser.spec.tsx b/libs/react-components/specs/datepicker.browser.spec.tsx index ce8a7a531c..e64bbf4028 100644 --- a/libs/react-components/specs/datepicker.browser.spec.tsx +++ b/libs/react-components/specs/datepicker.browser.spec.tsx @@ -144,6 +144,114 @@ describe("DatePicker", () => { expect(inputEl.disabled).toBe(true); }) }); + + describe("Width property", () => { + it("applies custom width with px units", async () => { + const Component = () => { + return ; + }; + + const result = render(); + const input = result.getByTestId("calendar-input"); + + await vi.waitFor(() => { + // Check the input element's computed style + const computedStyle = window.getComputedStyle(input.element()); + const inputWidth = parseFloat(computedStyle.width); + + // The width should be close to 400px (the underlying goa-input component handles the width) + expect(inputWidth).toBeGreaterThan(300); + expect(inputWidth).toBeLessThan(450); + }); + }); + + it("applies custom width with ch units", async () => { + const Component = () => { + return ; + }; + + const result = render(); + const input = result.getByTestId("calendar-input"); + + await vi.waitFor(() => { + // Check computed width is applied (browser converts ch to px) + const computedStyle = window.getComputedStyle(input.element()); + expect(computedStyle.width).toMatch(/^\d+(\.\d+)?px$/); + + // Should have a reasonable width for 25ch + const inputWidth = parseFloat(computedStyle.width); + expect(inputWidth).toBeGreaterThan(200); + expect(inputWidth).toBeLessThan(600); + }); + }); + + it("uses default width when not specified", async () => { + const Component = () => { + return ; + }; + + const result = render(); + const input = result.getByTestId("calendar-input"); + + await vi.waitFor(() => { + // Default width should be 16ch - check computed width + const computedStyle = window.getComputedStyle(input.element()); + const inputWidth = parseFloat(computedStyle.width); + + // 16ch should be around 150-300px depending on font + expect(inputWidth).toBeGreaterThan(100); + expect(inputWidth).toBeLessThan(400); + }); + }); + + it("supports percentage width units", async () => { + const Component = () => { + return ( +
+ +
+ ); + }; + + const result = render(); + const input = result.getByTestId("calendar-input"); + + await vi.waitFor(() => { + // Check computed width + const computedStyle = window.getComputedStyle(input.element()); + expect(computedStyle.width).toMatch(/^\d+(\.\d+)?px$/); + + // Should be a reasonable percentage of container + const inputWidth = parseFloat(computedStyle.width); + expect(inputWidth).toBeGreaterThan(50); + expect(inputWidth).toBeLessThan(800); + }); + }); + + it("maintains minimum width to ensure date display", async () => { + const Component = () => { + return ; + }; + + const result = render(); + const input = result.getByTestId("calendar-input"); + + await vi.waitFor(() => { + const inputEl = input.element() as HTMLInputElement; + + // Check that date value is displayed + expect(inputEl.value).toBeTruthy(); + expect(inputEl.value.length).toBeGreaterThan(0); + + // Check width is applied + const computedStyle = window.getComputedStyle(inputEl); + const inputWidth = parseFloat(computedStyle.width); + + // Should be wide enough to display date (20ch should be enough) + expect(inputWidth).toBeGreaterThan(150); + }); + }); + }); }); describe("Date Picker input type", () => { @@ -162,66 +270,59 @@ describe("Date Picker input type", () => { const result = render(); const datePickerMonth = result.getByTestId("input-month"); + const datePickerMonthMarch = result.getByTestId("dropdown-item-3"); const datePickerDay = result.getByTestId("input-day"); const datePickerYear = result.getByTestId("input-year"); - expect(datePickerMonth).toBeTruthy(); - expect(datePickerDay).toBeTruthy(); - expect(datePickerYear).toBeTruthy(); - const rootElChangeHandler = vi.fn(); result.container.addEventListener("_change", (e: Event) => { const ce = e as CustomEvent; - rootElChangeHandler(ce.detail.value); + rootElChangeHandler(ce.detail.valueStr); }); // Select month - if (datePickerMonth) { - await datePickerMonth.click(); - await userEvent.keyboard("{ArrowDown}"); - await userEvent.keyboard("{Enter}"); - } + await datePickerMonth.click(); + await userEvent.keyboard("{ArrowDown}"); + await userEvent.keyboard("{Enter}"); // should be null because date is invalid await vi.waitFor(() => { - expect(rootElChangeHandler).toHaveBeenCalledWith(null); + expect(rootElChangeHandler).toHaveBeenCalledWith(""); }); rootElChangeHandler.mockClear(); // Input day - if (datePickerDay) { - await datePickerDay.click(); - await userEvent.type(datePickerDay, "1"); - } + await datePickerDay.click(); + await userEvent.type(datePickerDay, "1"); + + // Select month + await userEvent.click(datePickerMonth); + await userEvent.click(datePickerMonthMarch); // should be null because date is still invalid await vi.waitFor(() => { - expect(rootElChangeHandler).toHaveBeenCalledWith(null); + expect(rootElChangeHandler).toHaveBeenCalledWith(""); }); rootElChangeHandler.mockClear(); // Input year - if (datePickerYear) { - await datePickerYear.click(); - await userEvent.type(datePickerYear, "1999"); - } + await datePickerYear.click(); + await userEvent.type(datePickerYear, "1999"); // should not be null because date became valid await vi.waitFor(() => { - expect(rootElChangeHandler).toHaveBeenCalledWith("1999-01-01"); + expect(rootElChangeHandler).toHaveBeenCalledWith("1999-03-01"); }); rootElChangeHandler.mockClear(); // Clear day input - if (datePickerDay) { - await datePickerDay.click(); - await userEvent.keyboard("{ArrowRight}"); - await userEvent.keyboard("{Backspace}"); - } + await datePickerDay.click(); + await userEvent.keyboard("{ArrowRight}"); + await userEvent.keyboard("{Backspace}"); // should be null because date became invalid await vi.waitFor(() => { - expect(rootElChangeHandler).toHaveBeenCalledWith(null); + expect(rootElChangeHandler).toHaveBeenCalledWith(""); }); rootElChangeHandler.mockClear(); }); diff --git a/libs/react-components/src/lib/calendar/calendar.spec.tsx b/libs/react-components/src/lib/calendar/calendar.spec.tsx index 1aba85654e..0317e03c0d 100644 --- a/libs/react-components/src/lib/calendar/calendar.spec.tsx +++ b/libs/react-components/src/lib/calendar/calendar.spec.tsx @@ -27,9 +27,9 @@ describe("Calendar", () => { }); it("should set the props correctly", () => { - const value = new Date(); - const min = addMonths(value, -1); - const max = addMonths(value, 1); + 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.toISOString()); - expect(el?.getAttribute("min")).toBe(min.toISOString()); - expect(el?.getAttribute("max")).toBe(max.toISOString()); + expect(el?.getAttribute("value")).toBe(value); + expect(el?.getAttribute("min")).toBe(min); + expect(el?.getAttribute("max")).toBe(max); expect(el?.getAttribute("testid")).toBe("foo"); }); }); diff --git a/libs/react-components/src/lib/calendar/calendar.tsx b/libs/react-components/src/lib/calendar/calendar.tsx index 84db318873..913c3bb323 100644 --- a/libs/react-components/src/lib/calendar/calendar.tsx +++ b/libs/react-components/src/lib/calendar/calendar.tsx @@ -20,9 +20,9 @@ declare module "react" { } export interface GoabCalendarProps extends Margins { name?: string; - value?: Date; - min?: Date; - max?: Date; + value?: string; + min?: string; + max?: string; testId?: string; onChange: (details: GoabCalendarOnChangeDetail) => void; } @@ -40,26 +40,32 @@ export function GoabCalendar({ onChange, }: GoabCalendarProps): JSX.Element { const ref = useRef(null); + useEffect(() => { if (!ref.current) { return; } const current = ref.current; - current.addEventListener("_change", (e: Event) => { + const listener = (e: Event) => { onChange({ name: name || "", value: (e as CustomEvent).detail.value, }); - }); - }); + } + current.addEventListener("_change", listener); + + return () => { + current.removeEventListener("_change", listener); + } + }, []); return ( void; } @@ -57,10 +59,17 @@ export function GoabDatePicker({ mb, ml, relative, + width, onChange, }: GoabDatePickerProps): JSX.Element { const ref = useRef(null); + 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; @@ -97,18 +106,19 @@ export function GoabDatePicker({ ); } diff --git a/libs/web-components/.eslintrc.json b/libs/web-components/.eslintrc.json index ca41bae324..1fec91a988 100644 --- a/libs/web-components/.eslintrc.json +++ b/libs/web-components/.eslintrc.json @@ -26,7 +26,7 @@ "error", { "ignoredFiles": ["{projectRoot}/vite.config.{js,ts,mjs,mts}"], - "ignoredDependencies": ["glob", "svelte", "@sveltejs/vite-plugin-svelte"] + "ignoredDependencies": ["glob", "date-fns", "svelte", "@sveltejs/vite-plugin-svelte"] } ] } diff --git a/libs/web-components/src/common/calendar-date.spec.ts b/libs/web-components/src/common/calendar-date.spec.ts new file mode 100644 index 0000000000..5e50dbe477 --- /dev/null +++ b/libs/web-components/src/common/calendar-date.spec.ts @@ -0,0 +1,460 @@ +import { describe, it, expect } from "vitest"; +import { CalendarDate } from "./calendar-date"; + +describe("CalendarDate", () => { + describe("parse", () => { + it("parses a string date in YYYY-MM-DD format", () => { + const result = CalendarDate.parse("2024-03-15"); + expect(result).toEqual([2024, 3, 15]); + }); + + it("parses a string date with ISO timestamp", () => { + const result = CalendarDate.parse("2024-03-15T10:30:00Z"); + expect(result).toEqual([2024, 3, 15]); + }); + + it("parses a Date object", () => { + const date = new Date(2024, 2, 15); // Month is 0-indexed + const result = CalendarDate.parse(date); + expect(result).toEqual([2024, 3, 15]); // Month is 1-indexed in result + }); + + it("parses an object with year, month, day", () => { + const result = CalendarDate.parse({ year: 2024, month: 3, day: 15 }); + expect(result).toEqual([2024, 3, 15]); + }); + }); + + describe("constructor", () => { + it("creates a CalendarDate from a string", () => { + const calDate = new CalendarDate("2024-03-15"); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(15); + }); + + it("creates a CalendarDate from a Date object", () => { + const date = new Date(2024, 2, 15); + const calDate = new CalendarDate(date); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(15); + }); + + it("creates a CalendarDate from an object", () => { + const calDate = new CalendarDate({ year: 2024, month: 3, day: 15 }); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(15); + }); + + it("creates a CalendarDate with current date when no value provided", () => { + const calDate = new CalendarDate(); + const now = new Date(); + expect(calDate.year).toBe(now.getFullYear()); + expect(calDate.month).toBe(now.getMonth() + 1); + expect(calDate.day).toBe(now.getDate()); + }); + }); + + describe("getters", () => { + const calDate = new CalendarDate("2024-03-15"); + + it("returns the year", () => { + expect(calDate.year).toBe(2024); + }); + + it("returns the month", () => { + expect(calDate.month).toBe(3); + }); + + it("returns the day", () => { + expect(calDate.day).toBe(15); + }); + + it("returns the date as a Date object", () => { + const date = calDate.date; + expect(date).toBeInstanceOf(Date); + expect(date.getFullYear()).toBe(2024); + expect(date.getMonth()).toBe(2); // 0-indexed + expect(date.getDate()).toBe(15); + }); + + it("returns the day of week", () => { + // March 15, 2024 is a Friday (5) + expect(calDate.dayOfWeek).toBe(5); + }); + + it("returns the number of days in the month", () => { + // March has 31 days + expect(calDate.daysInMonth).toBe(31); + }); + + it("returns the first day of the month", () => { + const firstDay = calDate.firstDayOfMonth; + expect(firstDay.year).toBe(2024); + expect(firstDay.month).toBe(3); + expect(firstDay.day).toBe(1); + }); + + it("returns the last day of the month", () => { + const lastDay = calDate.lastDayOfMonth; + expect(lastDay.year).toBe(2024); + expect(lastDay.month).toBe(3); + expect(lastDay.day).toBe(31); + }); + + it("returns the previous day", () => { + const prevDay = calDate.previousDay; + expect(prevDay.year).toBe(2024); + expect(prevDay.month).toBe(3); + expect(prevDay.day).toBe(14); + }); + + it("returns the next day", () => { + const nextDay = calDate.nextDay; + expect(nextDay.year).toBe(2024); + expect(nextDay.month).toBe(3); + expect(nextDay.day).toBe(16); + }); + + it("returns the previous month", () => { + const prevMonth = calDate.previousMonth; + expect(prevMonth.year).toBe(2024); + expect(prevMonth.month).toBe(2); + expect(prevMonth.day).toBe(15); + }); + + it("returns the next month", () => { + const nextMonth = calDate.nextMonth; + expect(nextMonth.year).toBe(2024); + expect(nextMonth.month).toBe(4); + expect(nextMonth.day).toBe(15); + }); + }); + + describe("setters", () => { + it("sets the year", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.setYear(2025); + expect(calDate.year).toBe(2025); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(15); + }); + + it("sets the month", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.setMonth(6); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(6); + expect(calDate.day).toBe(15); + }); + + it("sets the day and returns the instance", () => { + const calDate = new CalendarDate("2024-03-15"); + const result = calDate.setDay(20); + expect(result).toBe(calDate); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(20); + }); + }); + + describe("addYears", () => { + it("adds positive years", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.addYears(2); + expect(calDate.year).toBe(2026); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(15); + }); + + it("adds negative years", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.addYears(-2); + expect(calDate.year).toBe(2022); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(15); + }); + + it("returns the instance for chaining", () => { + const calDate = new CalendarDate("2024-03-15"); + const result = calDate.addYears(1); + expect(result).toBe(calDate); + }); + }); + + describe("addMonths", () => { + it("adds positive months within the same year", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.addMonths(2); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(5); + expect(calDate.day).toBe(15); + }); + + it("adds positive months across years", () => { + const calDate = new CalendarDate("2024-11-15"); + calDate.addMonths(3); + expect(calDate.year).toBe(2025); + expect(calDate.month).toBe(2); + expect(calDate.day).toBe(15); + }); + + it("adds negative months", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.addMonths(-2); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(1); + expect(calDate.day).toBe(15); + }); + + it("handles month overflow correctly", () => { + const calDate = new CalendarDate("2024-01-31"); + calDate.addMonths(1); + // January 31 + 1 month = February 29, 2024 (leap year) + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(2); + expect(calDate.day).toBe(29); + }); + + it("returns the instance for chaining", () => { + const calDate = new CalendarDate("2024-03-15"); + const result = calDate.addMonths(1); + expect(result).toBe(calDate); + }); + }); + + describe("addDays", () => { + it("adds positive days within the same month", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.addDays(5); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(20); + }); + + it("adds positive days across months", () => { + const calDate = new CalendarDate("2024-03-29"); + calDate.addDays(5); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(4); + expect(calDate.day).toBe(3); + }); + + it("adds positive days across years", () => { + const calDate = new CalendarDate("2024-12-30"); + calDate.addDays(5); + expect(calDate.year).toBe(2025); + expect(calDate.month).toBe(1); + expect(calDate.day).toBe(4); + }); + + it("adds negative days", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.addDays(-5); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(3); + expect(calDate.day).toBe(10); + }); + + it("adds negative days across months", () => { + const calDate = new CalendarDate("2024-03-02"); + calDate.addDays(-5); + expect(calDate.year).toBe(2024); + expect(calDate.month).toBe(2); + expect(calDate.day).toBe(26); + }); + + it("returns the instance for chaining", () => { + const calDate = new CalendarDate("2024-03-15"); + const result = calDate.addDays(1); + expect(result).toBe(calDate); + }); + }); + + describe("comparison methods", () => { + describe("isSameDay", () => { + it("returns true for the same day", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-03-15"); + expect(date1.isSameDay(date2)).toBe(true); + }); + + it("returns false for different days", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-03-16"); + expect(date1.isSameDay(date2)).toBe(false); + }); + + it("returns false for same day in different months", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-04-15"); + expect(date1.isSameDay(date2)).toBe(false); + }); + }); + + describe("isSameMonth", () => { + it("returns true for the same month", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-03-20"); + expect(date1.isSameMonth(date2)).toBe(true); + }); + + it("returns false for different months", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-04-15"); + expect(date1.isSameMonth(date2)).toBe(false); + }); + + it("returns false for same month in different years", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2025-03-15"); + expect(date1.isSameMonth(date2)).toBe(false); + }); + }); + + describe("isBefore", () => { + it("returns true when date is before comparison date", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-03-20"); + expect(date1.isBefore(date2)).toBe(true); + }); + + it("returns false when date is after comparison date", () => { + const date1 = new CalendarDate("2024-03-20"); + const date2 = new CalendarDate("2024-03-15"); + expect(date1.isBefore(date2)).toBe(false); + }); + + it("returns false when dates are the same", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-03-15"); + expect(date1.isBefore(date2)).toBe(false); + }); + }); + + describe("isAfter", () => { + it("returns true when date is after comparison date", () => { + const date1 = new CalendarDate("2024-03-20"); + const date2 = new CalendarDate("2024-03-15"); + expect(date1.isAfter(date2)).toBe(true); + }); + + it("returns false when date is before comparison date", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-03-20"); + expect(date1.isAfter(date2)).toBe(false); + }); + + it("returns false when dates are the same", () => { + const date1 = new CalendarDate("2024-03-15"); + const date2 = new CalendarDate("2024-03-15"); + expect(date1.isAfter(date2)).toBe(false); + }); + }); + }); + + describe("clone", () => { + it("creates a new instance with the same date", () => { + const original = new CalendarDate("2024-03-15"); + const cloned = original.clone(); + + expect(cloned).not.toBe(original); + expect(cloned.year).toBe(original.year); + expect(cloned.month).toBe(original.month); + expect(cloned.day).toBe(original.day); + }); + + it("creates an independent instance", () => { + const original = new CalendarDate("2024-03-15"); + const cloned = original.clone(); + + cloned.addDays(5); + + expect(original.day).toBe(15); + expect(cloned.day).toBe(20); + }); + }); + + describe("isValid", () => { + it("returns true for a valid date", () => { + const calDate = new CalendarDate("2024-03-15"); + expect(calDate.isValid()).toBe(true); + }); + + it("returns true for edge case dates", () => { + const leapDay = new CalendarDate("2024-02-29"); + expect(leapDay.isValid()).toBe(true); + }); + }); + + describe("format", () => { + it("formats the date using date-fns format string", () => { + const calDate = new CalendarDate("2024-03-15"); + expect(calDate.format("yyyy-MM-dd")).toBe("2024-03-15"); + }); + + it("formats with different patterns", () => { + const calDate = new CalendarDate("2024-03-15"); + expect(calDate.format("MMM d, yyyy")).toBe("Mar 15, 2024"); + }); + + it("formats with full month name", () => { + const calDate = new CalendarDate("2024-03-15"); + expect(calDate.format("MMMM d, yyyy")).toBe("March 15, 2024"); + }); + }); + + describe("toString", () => { + it("returns the date as hyphen-separated values", () => { + const calDate = new CalendarDate({ year: 2024, month: 3, day: 15 }); + const result = calDate.toString(); + expect(result).toBe("2024-03-15"); + }); + + it("can be used to create a new CalendarDate", () => { + const original = new CalendarDate({ year: 2024, month: 3, day: 15 }); + const str = original.toString(); + const cloned = new CalendarDate(str); + expect(cloned.year).toBe(original.year); + expect(cloned.month).toBe(original.month); + expect(cloned.day).toBe(original.day); + }); + }); + + describe("edge cases", () => { + it("handles leap year correctly", () => { + const leapDay = new CalendarDate("2024-02-29"); + expect(leapDay.daysInMonth).toBe(29); + expect(leapDay.isValid()).toBe(true); + }); + + it("handles non-leap year correctly", () => { + const feb2023 = new CalendarDate("2023-02-15"); + expect(feb2023.daysInMonth).toBe(28); + }); + + it("handles month boundaries when adding days", () => { + const endOfMonth = new CalendarDate("2024-01-31"); + endOfMonth.addDays(1); + expect(endOfMonth.month).toBe(2); + expect(endOfMonth.day).toBe(1); + }); + + it("handles year boundaries when adding months", () => { + const endOfYear = new CalendarDate("2024-12-15"); + endOfYear.addMonths(1); + expect(endOfYear.year).toBe(2025); + expect(endOfYear.month).toBe(1); + }); + + it("handles chaining multiple operations", () => { + const calDate = new CalendarDate("2024-03-15"); + calDate.addYears(1).addMonths(2).addDays(5); + expect(calDate.year).toBe(2025); + expect(calDate.month).toBe(5); + expect(calDate.day).toBe(20); + }); + }); +}); diff --git a/libs/web-components/src/common/calendar-date.ts b/libs/web-components/src/common/calendar-date.ts new file mode 100644 index 0000000000..55fa06b03a --- /dev/null +++ b/libs/web-components/src/common/calendar-date.ts @@ -0,0 +1,195 @@ +import { + addMonths as _addMonths, + addDays as _addDays, + format as _format, + getDaysInMonth as _getDaysInMonth, + isSameDay as _isSameDay, + lastDayOfMonth as _lastDayOfMonth, + setDate as _setDate, + isSameMonth as _isSameMonth, + isBefore as _isBefore, + addYears as _addYears, + isAfter as _isAfter, +} from "date-fns"; + +type CalendarDateInput = + | string + | Date + | 0 + | { year: number; month: number; day: number }; + +export class CalendarDate { + private _dateNums: number[]; + + static parse(value: CalendarDateInput): number[] { + if (typeof value === "string") { + value = value.split("T")[0]; + return value.split("-").map((v) => +v); + } else if (value instanceof Date) { + return [value.getFullYear(), value.getMonth() + 1, value.getDate()]; + } else if (value === 0) { + return [0, 0, 0]; + } else { + return [value.year, value.month, value.day]; + } + } + + static init(): CalendarDate { + return new CalendarDate(0); + } + + constructor(value?: CalendarDateInput) { + if (value || value === 0) { + this._dateNums = CalendarDate.parse(value); + } else { + this._dateNums = CalendarDate.parse(new Date()); + } + } + + // Used internally to get the date value for the date_fns + get date(): Date { + return new Date( + this._dateNums[0], + this._dateNums[1] - 1, + this._dateNums[2], + ); + } + + get year(): number { + return this._dateNums[0]; + } + + get month(): number { + return this._dateNums[1]; + } + + get day(): number { + return this._dateNums[2]; + } + + get dayOfWeek(): number { + return this.date.getDay(); + } + + get daysInMonth(): number { + return _getDaysInMonth(this.date); + } + + get firstDayOfMonth(): CalendarDate { + return new CalendarDate({ year: this.year, month: this.month, day: 1 }); + } + + get lastDayOfMonth(): CalendarDate { + return new CalendarDate(_lastDayOfMonth(this.date)); + } + + get previousDay(): CalendarDate { + return this.clone().addDays(-1); + } + + get nextDay(): CalendarDate { + return this.clone().addDays(1); + } + + get previousWeek(): CalendarDate { + return this.clone().addDays(-7); + } + + get nextWeek(): CalendarDate { + return this.clone().addDays(7); + } + + get previousMonth(): CalendarDate { + return this.clone().addMonths(-1); + } + + get nextMonth(): CalendarDate { + return this.clone().addMonths(1); + } + + clone(): CalendarDate { + return new CalendarDate(this.toString()); + } + + setYear(val: number) { + this._dateNums[0] = val; + } + + setMonth(val: number) { + this._dateNums[1] = val; + } + + setDay(val: number): CalendarDate { + this._dateNums[2] = val; + return this; + } + + addYears(count: number): CalendarDate { + this._dateNums[0] += count; + return this; + } + + addMonths(count: number): CalendarDate { + this._dateNums = CalendarDate.parse(_addMonths(this.date, count)); + return this; + } + + addDays(count: number): CalendarDate { + this._dateNums = CalendarDate.parse(_addDays(this.date, count)); + return this; + } + + isSameDay(cmp: CalendarDate): boolean { + return _isSameDay(this.date, cmp.date); + } + + isSameMonth(value: CalendarDate): boolean { + return _isSameMonth(this.date, value.date); + } + + isBefore(cmp: CalendarDate): boolean { + return _isBefore(this.date, cmp.date); + } + + isAfter(cmp: CalendarDate): boolean { + return _isAfter(this.date, cmp.date); + } + + isZero(): boolean { + return ( + this._dateNums[0] === 0 && + this._dateNums[1] === 0 && + this._dateNums[2] === 0 + ); + } + + isValid(): boolean { + // ensure it's a valid date + // E.g. "2025-02-31" would be invalid because the date does not exist + const comparisonDate = new Date(this.toString()); + if ( + isNaN(comparisonDate.getTime()) || + this.toString() !== comparisonDate.toISOString().split("T")[0] + ) { + return false; + } + + return true; + } + + format(tmpl: string): string { + if (this.isZero()) { + return ""; + } + return _format(this.date, tmpl); + } + + toString(): string { + if (this.isZero()) { + return ""; + } + return this._dateNums + .map((num) => (`${num}`.length < 2 ? `0${num}` : `${num}`)) + .join("-"); + } +} diff --git a/libs/web-components/src/components/calendar/Calendar.svelte b/libs/web-components/src/components/calendar/Calendar.svelte index eb71fe61e3..ab8a73250c 100644 --- a/libs/web-components/src/components/calendar/Calendar.svelte +++ b/libs/web-components/src/components/calendar/Calendar.svelte @@ -2,23 +2,9 @@ @@ -327,11 +305,11 @@ data-testid="months" width="160px" maxheight="240px" - value={_calendarDate?.getMonth()} + value={_calendarDate?.month} on:_change={setMonth} > {#each _months as month, i} - + {/each}
@@ -344,7 +322,7 @@ data-testid="years" width="104px" maxheight="240px" - value={_calendarDate?.getFullYear()} + value={_calendarDate?.year} on:_change={setYear} > {#each _years as year} @@ -364,49 +342,46 @@
Sat
{#each _previousMonthDays as d} {/each} {#each _monthDays as d} {/each} {#each _nextMonthDays as d} {/each}
diff --git a/libs/web-components/src/components/calendar/calendar.spec.ts b/libs/web-components/src/components/calendar/calendar.spec.ts index 9eae33a8a0..5d4ec3b081 100644 --- a/libs/web-components/src/components/calendar/calendar.spec.ts +++ b/libs/web-components/src/components/calendar/calendar.spec.ts @@ -19,7 +19,7 @@ function toDayStart(d: Date): Date { it("it renders", async () => { const { container, queryByTestId } = render(Calendar); - await tick() + await tick(); const monthsEl = queryByTestId("months"); const yearsEl = queryByTestId("years"); @@ -34,7 +34,7 @@ it("it renders", async () => { const d = new Date(lastDate); d.setDate(i); const dayEl = container - ?.querySelector(`[data-date="${d.getTime()}"]`) + ?.querySelector(`[data-date="${getDateStamp(d)}"]`) ?.querySelector("[data-testid=date]"); expect(dayEl).toBeTruthy(); } @@ -42,15 +42,16 @@ it("it renders", async () => { // today's date const today = toDayStart(new Date()); const todayEl = container - ?.querySelector(`.today[data-date="${today.getTime()}"]`) + ?.querySelector(`.today[data-date="${getDateStamp(today)}"]`) ?.querySelector("[data-testid=date]"); expect(todayEl).toBeTruthy(); // months - const monthEls = queryByTestId("months")?.querySelectorAll("goa-dropdown-item"); + const monthEls = + queryByTestId("months")?.querySelectorAll("goa-dropdown-item"); expect(monthEls?.length).toBe(12); - for (let i = 0; i < 12; i++) { + for (let i = 1; i <= 12; i++) { const month = queryByTestId("months")?.querySelector( `goa-dropdown-item[value="${i}"]`, ); @@ -60,7 +61,7 @@ it("it renders", async () => { it("should have no date selected if one not provided", async () => { const { container } = render(Calendar); - await tick() + await tick(); const selectedDate = container.querySelector(".selected"); expect(selectedDate).toBeFalsy(); @@ -69,12 +70,12 @@ it("should have no date selected if one not provided", async () => { it("sets the preset date value", async () => { const date = new Date().toISOString(); const { container } = render(Calendar, { value: date }); - await tick() + await tick(); const timestamp = toDayStart(new Date(date)); const dayEl = container - .querySelector(`.selected[data-date="${timestamp.getTime()}"]`) - .querySelector("[data-testid=date]"); + .querySelector(`.selected[data-date="${getDateStamp(timestamp)}"]`) + ?.querySelector("[data-testid=date]"); expect(dayEl).toBeTruthy(); }); @@ -84,13 +85,13 @@ it("provides the defined year range", async () => { const min = new Date(now.getFullYear() - diff, now.getMonth(), now.getDate()); const max = new Date(now.getFullYear() + diff, now.getMonth(), now.getDate()); const { queryByTestId } = render(Calendar, { min, max }); - await tick() + await tick(); - const years = queryByTestId("years").querySelectorAll("goa-dropdown-item"); + const years = queryByTestId("years")?.querySelectorAll("goa-dropdown-item"); - expect(years.length).toBe(11); // has to be one more than the count to include the first and last + expect(years?.length).toBe(11); // has to be one more than the count to include the first and last for (let i = 0; i < diff * 2 + 1; i++) { - const year = queryByTestId("years").querySelector( + const year = queryByTestId("years")?.querySelector( `goa-dropdown-item[value="${min.getFullYear() + i}"]`, ); expect(year).toBeTruthy(); @@ -111,13 +112,13 @@ it("show the default year range", async () => { now.getDate(), ); const { queryByTestId } = render(Calendar, { min, max }); - await tick() + await tick(); - const years = queryByTestId("years").querySelectorAll("goa-dropdown-item"); + const years = queryByTestId("years")?.querySelectorAll("goa-dropdown-item"); - expect(years.length).toBe(21); // has to be one more than the count to include the first and last + expect(years?.length).toBe(21); // has to be one more than the count to include the first and last for (let i = 0; i < defaultDiff * 2 + 1; i++) { - const year = queryByTestId("years").querySelector( + const year = queryByTestId("years")?.querySelector( `goa-dropdown-item[value="${min.getFullYear() + i}"]`, ); expect(year).toBeTruthy(); @@ -127,30 +128,34 @@ it("show the default year range", async () => { it("emits an event when a date is selected", async () => { const name = "birthdate"; const { container, queryByTestId } = render(Calendar, { name }); - await tick() + await tick(); const today = toDayStart(new Date()); const todayEl = container.querySelector( - `button.today[data-date="${today.getTime()}"]`, + `button.today[data-date="${getDateStamp(today)}"]`, ); expect(todayEl).toBeTruthy(); const onChange = vi.fn(); const calendarEl = queryByTestId("calendar"); - calendarEl.addEventListener("_change", (e) => { + calendarEl?.addEventListener("_change", (e) => { onChange((e as CustomEvent).detail); }); - await fireEvent.click(todayEl); + await fireEvent.click(todayEl!); await waitFor(() => { expect(onChange).toBeCalled(); - expect(onChange).toBeCalledWith({ type: "date", value: today, name }); + expect(onChange).toBeCalledWith({ + type: "string", + value: getDateStamp(today), + name, + }); }); }); it("updates the calendar when a new month is selected", async () => { const { container, queryByTestId } = render(Calendar); - await tick() + await tick(); const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const monthsEl = queryByTestId("months"); @@ -160,53 +165,61 @@ it("updates the calendar when a new month is selected", async () => { const date = toDayStart(new Date()); date.setDate(1); const dayOfWeek = date.getDay(); - const buttonEl = container.querySelector(`[data-date="${date.getTime()}"]`); - const dayEl = buttonEl.querySelector("[data-testid=date]"); - expect(dayEl.innerHTML).toBe("1"); + const buttonEl = container.querySelector( + `[data-date="${getDateStamp(date)}"]`, + ); + const dayEl = buttonEl?.querySelector("[data-testid=date]"); + expect(dayEl?.innerHTML).toBe("1"); expect((buttonEl as HTMLElement).dataset.day).toBe(dayNames[dayOfWeek]); } // change month - const otherMonthZeroIndex = (new Date().getMonth() + 1) % 11; - monthsEl.dispatchEvent( + 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: otherMonthZeroIndex }, + detail: { value: otherMonth }, }), ); await waitFor(() => { const date = toDayStart(new Date()); - date.setMonth(otherMonthZeroIndex); + date.setMonth(otherMonth - 1); // revert to 0-index value date.setDate(1); const dayOfWeek = date.getDay(); - const buttonEl = container.querySelector(`[data-date="${date.getTime()}"]`); - const dayEl = buttonEl.querySelector("[data-testid=date]"); - expect(dayEl.innerHTML).toBe("1"); + const buttonEl = queryByTestId(getDateStamp(date)); + + expect(buttonEl).toBeTruthy(); + const dayEl = buttonEl?.querySelector("[data-testid=date]"); + expect(dayEl?.innerHTML).toBe("1"); expect((buttonEl as HTMLElement).dataset.day).toBe(dayNames[dayOfWeek]); }); }); it("updates the calendar when a new year is selected", async () => { const { container, queryByTestId } = render(Calendar); - await tick() + await tick(); const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const yearsEl = queryByTestId("years"); // validate the day of the first day for the current month - { - const date = toDayStart(new Date()); - date.setDate(1); - const dayOfWeek = date.getDay(); - const buttonEl = container.querySelector(`[data-date="${date.getTime()}"]`); - const dayEl = buttonEl.querySelector("[data-testid=date]"); - expect(dayEl.innerHTML).toBe("1"); + const date = toDayStart(new Date()); + date.setDate(1); + const dayOfWeek = date.getDay(); + const buttonEl = queryByTestId(getDateStamp(date)); + + expect(buttonEl).toBeTruthy(); + const dayEl = buttonEl?.querySelector("[data-testid=date]"); + + await waitFor(() => { + expect(dayEl).toBeTruthy(); + expect(dayEl?.innerHTML).toBe("1"); expect((buttonEl as HTMLElement).dataset.day).toBe(dayNames[dayOfWeek]); - } + }); // change year const otherYearZeroIndex = new Date().getFullYear() + 1; - yearsEl.dispatchEvent( + yearsEl?.dispatchEvent( new CustomEvent("_change", { detail: { value: otherYearZeroIndex }, }), @@ -217,20 +230,25 @@ it("updates the calendar when a new year is selected", async () => { date.setFullYear(otherYearZeroIndex); date.setDate(1); const dayOfWeek = date.getDay(); - const buttonEl = container.querySelector(`[data-date="${date.getTime()}"]`); - const dayEl = buttonEl.querySelector("[data-testid=date]"); + const buttonEl = queryByTestId(getDateStamp(date)); + const dayEl = buttonEl?.querySelector("[data-testid=date]"); expect(dayEl).toBeTruthy(); - expect(dayEl.innerHTML).toBe("1"); + expect(dayEl?.innerHTML).toBe("1"); expect((buttonEl as HTMLElement).dataset.day).toBe(dayNames[dayOfWeek]); }); }); it("handle the arrow key presses", async () => { const { container, queryByTestId } = render(Calendar, { value: new Date() }); - await tick() + await tick(); let timestamp = toDayStart(new Date()); const calendarEl = queryByTestId("calendar"); + expect(calendarEl).toBeTruthy(); + + if (!calendarEl) { + return; + } const arrowLeftEvent = createEvent.keyDown(calendarEl, { key: "ArrowLeft" }); const arrowRightEvent = createEvent.keyDown(calendarEl, { @@ -244,9 +262,9 @@ it("handle the arrow key presses", async () => { await fireEvent(calendarEl, arrowLeftEvent); await waitFor(() => { const current = container - .querySelector(`[tabindex="0"]`) - .querySelector("[data-testid=date]"); - expect(current.innerHTML).toBe(`${timestamp.getDate()}`); + ?.querySelector(`[tabindex="0"]`) + ?.querySelector("[data-testid=date]"); + expect(current?.innerHTML).toBe(`${timestamp.getDate()}`); }); // Right arrow @@ -254,9 +272,9 @@ it("handle the arrow key presses", async () => { await fireEvent(calendarEl, arrowRightEvent); await waitFor(() => { const current = container - .querySelector(`[tabindex="0"]`) - .querySelector("[data-testid=date]"); - expect(current.innerHTML).toBe(`${timestamp.getDate()}`); + ?.querySelector(`[tabindex="0"]`) + ?.querySelector("[data-testid=date]"); + expect(current?.innerHTML).toBe(`${timestamp.getDate()}`); }); // Up arrow @@ -264,9 +282,9 @@ it("handle the arrow key presses", async () => { await fireEvent(calendarEl, arrowUpEvent); await waitFor(() => { const current = container - .querySelector(`[tabindex="0"]`) - .querySelector("[data-testid=date]"); - expect(current.innerHTML).toBe(`${timestamp.getDate()}`); + ?.querySelector(`[tabindex="0"]`) + ?.querySelector("[data-testid=date]"); + expect(current?.innerHTML).toBe(`${timestamp.getDate()}`); }); // // Down arrow @@ -274,9 +292,9 @@ it("handle the arrow key presses", async () => { await fireEvent(calendarEl, arrowDownEvent); await waitFor(() => { const current = container - .querySelector(`[tabindex="0"]`) - .querySelector("[data-testid=date]"); - expect(current.innerHTML).toBe(`${timestamp.getDate()}`); + ?.querySelector(`[tabindex="0"]`) + ?.querySelector("[data-testid=date]"); + expect(current?.innerHTML).toBe(`${timestamp.getDate()}`); }); }); @@ -285,29 +303,28 @@ it("prevents date click selection outside of allowed range", async () => { const max = new Date(); const today = startOfDay(new Date()); const { container, queryByTestId } = render(Calendar, { min, max }); - await tick() + await tick(); const yesterday = addDays(today, -1); const tomorrow = addDays(today, +1); const onChange = vi.fn(); const calendarEl = queryByTestId("calendar"); - calendarEl.addEventListener("_change", (e) => { + calendarEl?.addEventListener("_change", (e) => { onChange((e as CustomEvent).detail); }); const yesterdayEl = container.querySelector( - `[data-date="${yesterday.getTime()}"]`, + `[data-date="${getDateStamp(yesterday)}"]`, ); - if (yesterdayEl) { - await fireEvent.click(yesterdayEl); - } + expect(yesterdayEl).toBeTruthy(); + await fireEvent.click(yesterdayEl!); + const tomorrowEl = container.querySelector( - `[data-date="${tomorrow.getTime()}"]`, + `[data-date="${getDateStamp(tomorrow)}"]`, ); - if (tomorrowEl) { - await fireEvent.click(tomorrowEl); - } + expect(tomorrowEl).toBeTruthy(); + await fireEvent.click(tomorrowEl!); expect(onChange).not.toBeCalled(); }); @@ -316,14 +333,18 @@ it("prevents date keyboard selection outside of allowed range", async () => { const min = new Date(); // today is the only date selectable const max = new Date(); const { queryByTestId } = render(Calendar, { min, max }); - await tick() + await tick(); const onChange = vi.fn(); const calendarEl = queryByTestId("calendar"); - calendarEl.addEventListener("_change", (e) => { + calendarEl?.addEventListener("_change", (e) => { onChange((e as CustomEvent).detail); }); + if (!calendarEl) { + return; + } + const arrowLeftEvent = createEvent.keyDown(calendarEl, { key: "ArrowLeft" }); const arrowRightEvent = createEvent.keyDown(calendarEl, { key: "ArrowRight", @@ -341,88 +362,16 @@ it("prevents date keyboard selection outside of allowed range", async () => { }); }); -it("reacts to dynamic min date changes", async () => { - // Create a min date that's definitely before today - // Use the day before yesterday to ensure today is enabled - const initialMin = addDays(new Date(), -2); - initialMin.setHours(0, 0, 0, 0); // Set to start of day - - const { component, container } = render(Calendar, { - min: initialMin.toISOString(), - }); - await tick(); - - // Verify today is initially enabled - const today = startOfDay(new Date()); - let todayEl = container.querySelector(`[data-date="${today.getTime()}"]`); - expect(todayEl).not.toHaveClass("disabled"); - - // Update min to tomorrow - const newMin = addDays(today, 1); - newMin.setHours(0, 0, 0, 0); // Set to start of day - await component.$set({ min: newMin.toISOString() }); - await tick(); - - // Today should now be disabled - todayEl = container.querySelector(`[data-date="${today.getTime()}"]`); - expect(todayEl).toHaveClass("disabled"); - - // Tomorrow should be enabled - const tomorrow = addDays(today, 1); - const tomorrowEl = container.querySelector( - `[data-date="${tomorrow.getTime()}"]`, - ); - expect(tomorrowEl).not.toHaveClass("disabled"); -}); - -it("reacts to dynamic max date changes", async () => { - // Create a max date that's definitely after today - // Use the day after tomorrow to ensure today is enabled - const initialMax = addDays(new Date(), 2); - initialMax.setHours(0, 0, 0, 0); // Set to start of day - - const { component, container } = render(Calendar, { - max: initialMax.toISOString(), - }); - await tick(); - - // Verify today is initially enabled - const today = startOfDay(new Date()); - let todayEl = container.querySelector(`[data-date="${today.getTime()}"]`); - expect(todayEl).not.toHaveClass("disabled"); - - // Verify tomorrow is initially enabled - const tomorrow = addDays(today, 1); - let tomorrowEl = container.querySelector( - `[data-date="${tomorrow.getTime()}"]`, - ); - expect(tomorrowEl).not.toHaveClass("disabled"); - - // Update max to yesterday - const newMax = addDays(today, -1); - newMax.setHours(0, 0, 0, 0); // Set to start of day - await component.$set({ max: newMax.toISOString() }); - await tick(); - - // Today should now be disabled - todayEl = container.querySelector(`[data-date="${today.getTime()}"]`); - if(todayEl) { - expect(todayEl).toHaveClass("disabled"); - } - - // Tomorrow should be disabled - tomorrowEl = container.querySelector(`[data-date="${tomorrow.getTime()}"]`); - if(tomorrowEl) { - expect(tomorrowEl).toHaveClass("disabled"); - } +function pad(num: number): string { + return num >= 10 ? `${num}` : `0${num}`; +} - // Yesterday should be enabled - const yesterday = addDays(today, -1); - const yesterdayEl = container.querySelector( - `[data-date="${yesterday.getTime()}"]`, - ); - expect(yesterdayEl).not.toHaveClass("disabled"); -}); +function getDateStamp(date: Date): string { + const y = date.getFullYear(); + const m = pad(date.getMonth() + 1); + const d = pad(date.getDate()); + return `${y}-${m}-${d}`; +} it("updates year dropdown when min date changes", async () => { // Set initial min/max to create a specific range of years @@ -431,40 +380,48 @@ it("updates year dropdown when min date changes", async () => { const { component, queryByTestId } = render(Calendar, { min: initialMin.toISOString(), - max: initialMax.toISOString() + max: initialMax.toISOString(), }); await tick(); // Check initial years in dropdown let yearDropdown = queryByTestId("years"); - let yearOptions = yearDropdown.querySelectorAll("goa-dropdown-item"); + let yearOptions = yearDropdown?.querySelectorAll("goa-dropdown-item"); // Should be 6 years: 2020, 2021, 2022, 2023, 2024, 2025 - expect(yearOptions.length).toBe(6); + expect(yearOptions?.length).toBe(6); // Verify first and last year options - expect(yearOptions[0].getAttribute("value")).toBe("2020"); - expect(yearOptions[yearOptions.length - 1].getAttribute("value")).toBe("2025"); + expect(yearOptions?.[0].getAttribute("value")).toBe("2020"); + expect(yearOptions?.[yearOptions.length - 1].getAttribute("value")).toBe( + "2025", + ); // Update min to 2022, reducing the range const newMin = new Date(2022, 0, 1); - await component.$set({ min: newMin.toISOString() }); + component.$set({ min: newMin.toISOString() }); await tick(); // Check updated years in dropdown yearDropdown = queryByTestId("years"); - yearOptions = yearDropdown.querySelectorAll("goa-dropdown-item"); + yearOptions = yearDropdown?.querySelectorAll("goa-dropdown-item"); // Should now be 4 years: 2022, 2023, 2024, 2025 - expect(yearOptions.length).toBe(4); + expect(yearOptions?.length).toBe(4); // Verify updated first year and unchanged last year - expect(yearOptions[0].getAttribute("value")).toBe("2022"); - expect(yearOptions[yearOptions.length - 1].getAttribute("value")).toBe("2025"); + expect(yearOptions?.[0].getAttribute("value")).toBe("2022"); + expect(yearOptions?.[yearOptions.length - 1].getAttribute("value")).toBe( + "2025", + ); // Verify that 2020 and 2021 are no longer available - const year2020 = yearDropdown.querySelector("goa-dropdown-item[value='2020']"); - const year2021 = yearDropdown.querySelector("goa-dropdown-item[value='2021']"); + const year2020 = yearDropdown?.querySelector( + "goa-dropdown-item[value='2020']", + ); + const year2021 = yearDropdown?.querySelector( + "goa-dropdown-item[value='2021']", + ); expect(year2020).toBeFalsy(); expect(year2021).toBeFalsy(); }); @@ -476,40 +433,48 @@ it("updates year dropdown when max date changes", async () => { const { component, queryByTestId } = render(Calendar, { min: initialMin.toISOString(), - max: initialMax.toISOString() + max: initialMax.toISOString(), }); await tick(); // Check initial years in dropdown let yearDropdown = queryByTestId("years"); - let yearOptions = yearDropdown.querySelectorAll("goa-dropdown-item"); + let yearOptions = yearDropdown?.querySelectorAll("goa-dropdown-item"); // Should be 6 years: 2020, 2021, 2022, 2023, 2024, 2025 - expect(yearOptions.length).toBe(6); + expect(yearOptions?.length).toBe(6); // Verify first and last year options - expect(yearOptions[0].getAttribute("value")).toBe("2020"); - expect(yearOptions[yearOptions.length - 1].getAttribute("value")).toBe("2025"); + expect(yearOptions?.[0].getAttribute("value")).toBe("2020"); + expect(yearOptions?.[yearOptions.length - 1].getAttribute("value")).toBe( + "2025", + ); // Update max to 2023, reducing the range const newMax = new Date(2023, 0, 1); - await component.$set({ max: newMax.toISOString() }); + component.$set({ max: newMax.toISOString() }); await tick(); // Check updated years in dropdown yearDropdown = queryByTestId("years"); - yearOptions = yearDropdown.querySelectorAll("goa-dropdown-item"); + yearOptions = yearDropdown?.querySelectorAll("goa-dropdown-item"); // Should now be 4 years: 2020, 2021, 2022, 2023 - expect(yearOptions.length).toBe(4); + expect(yearOptions?.length).toBe(4); // Verify unchanged first year and updated last year - expect(yearOptions[0].getAttribute("value")).toBe("2020"); - expect(yearOptions[yearOptions.length - 1].getAttribute("value")).toBe("2023"); + expect(yearOptions?.[0].getAttribute("value")).toBe("2020"); + expect(yearOptions?.[yearOptions.length - 1].getAttribute("value")).toBe( + "2023", + ); // Verify that 2024 and 2025 are no longer available - const year2024 = yearDropdown.querySelector("goa-dropdown-item[value='2024']"); - const year2025 = yearDropdown.querySelector("goa-dropdown-item[value='2025']"); + const year2024 = yearDropdown?.querySelector( + "goa-dropdown-item[value='2024']", + ); + const year2025 = yearDropdown?.querySelector( + "goa-dropdown-item[value='2025']", + ); expect(year2024).toBeFalsy(); expect(year2025).toBeFalsy(); -}); \ No newline at end of file +}); diff --git a/libs/web-components/src/components/date-picker/DatePicker.svelte b/libs/web-components/src/components/date-picker/DatePicker.svelte index 2c0ce4014a..3603b80ad8 100644 --- a/libs/web-components/src/components/date-picker/DatePicker.svelte +++ b/libs/web-components/src/components/date-picker/DatePicker.svelte @@ -8,10 +8,9 @@ />