diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..41834bb9c0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = tab +max_line_length = 150 +insert_final_newline = true +trim_trailing_whitespace = true +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false + +[*.yml] +indent_style = space diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..6badc7c413 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,9 @@ +node_modules +dist +.angular +coverage/ +playwright-report/ +test-results/ +.svelte-kit/ +demo/vite-env.d.ts +demo/src/app.d.ts diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..5343d913ef --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "root": true, + "env": { + "browser": true, + "es2021": true + }, + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "@agnos-ui"], + "overrides": [ + { + "env": { + "node": true + }, + "files": ["scripts/**/*.js"], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ], + "rules": { + "prefer-const": ["error", {"destructuring": "all"}], + "@typescript-eslint/no-unused-vars": ["error", {"vars": "all", "args": "none", "ignoreRestSiblings": false}], + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/consistent-type-imports": "error" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..cc9347ea44 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..39cd5f034f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,73 @@ +name: build +on: + workflow_call: + secrets: + NPM_TOKEN: + description: 'NPM token to use to publish packages' + required: false + inputs: + version: + description: 'Version number (x.y.z) to set before executing the build' + type: string + default: '' + npmPublish: + description: 'Whether to publish the package on npm' + type: boolean + default: false + docPublish: + description: 'Whether to publish the documentation on gh-pages' + type: boolean + default: false +jobs: + build: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18.x' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm run syncpack:check + - if: inputs.version != '' + run: | + node scripts/setVersion.js "${{ inputs.version }}" + git config --global user.name github-actions + git config --global user.email github-actions@github.com + git commit -a -m v${{ inputs.version }} + git tag v${{ inputs.version }} + git show HEAD + - run: npm run build:ci + - run: npm run format:check + - run: npm run lint + - run: npx playwright install --with-deps + - run: npm run test + - run: npm run e2e + - if: inputs.docPublish + uses: actions/checkout@v3 + with: + ref: gh-pages + path: gh-pages + - run: ./scripts/npmPublish.sh --dry-run + - if: inputs.version != '' && inputs.npmPublish + run: | + npm whoami + ./scripts/npmPublish.sh --provenance + git push origin v${{ inputs.version }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - if: inputs.version != '' && inputs.docPublish + env: + VERSION: ${{ inputs.version }} + working-directory: gh-pages + run: | + rm -rf v${VERSION%.*} + cp -a ../demo/dist v${VERSION%.*} + rm -f latest && ln -s v$(npx semver v* | tail -1) latest + git add . + git commit --allow-empty -a -m "v${{ inputs.version }} from ${{ github.sha }}" + git push origin gh-pages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..893e9bbb6c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,9 @@ +name: ci +on: + push: + branches: [master] + pull_request: + branches: [master] +jobs: + build: + uses: './.github/workflows/build.yml' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..ef60fb9bf5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,18 @@ +name: release +on: + workflow_dispatch: + inputs: + version: + type: string + required: true + description: Version number (x.y.z) + +jobs: + build: + uses: './.github/workflows/build.yml' + with: + version: ${{ inputs.version }} + npmPublish: true + docPublish: true + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..31848bcd97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +.angular +coverage/ +playwright-report/ +test-results/ +.svelte-kit/ +dist.tar.gz diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..0da96d6baa --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx pretty-quick --staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..3b91d94904 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +dist +.angular +coverage/ +playwright-report/ +test-results/ +.svelte-kit/ diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000000..b031097738 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,8 @@ +const os = require('os'); + +/** @type import("prettier").Options */ +module.exports = { + bracketSpacing: false, + endOfLine: os.EOL === '\r\n' ? 'crlf' : 'lf', + plugins: ['prettier-plugin-svelte'], +}; diff --git a/.syncpackrc.js b/.syncpackrc.js new file mode 100644 index 0000000000..b4ff26bf63 --- /dev/null +++ b/.syncpackrc.js @@ -0,0 +1,26 @@ +/** @type import("syncpack").RcFile */ +module.exports = { + indent: '\t', + semverRange: '^', + versionGroups: [ + { + label: "Use '*' under 'peerDependencies' everywhere", + packages: ['**'], + dependencies: ['**'], + dependencyTypes: ['peer'], + pinVersion: '*', + }, + ], + semverGroups: [ + { + range: '~', + dependencies: ['typescript'], + packages: ['**'], + }, + { + range: '', + dependencies: ['@amadeus-it-group/tansu', '@agnos-ui/*'], + packages: ['**'], + }, + ], +}; diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..e3c8f7a5b2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-node", + "request": "launch", + "name": "Vitest debug", + "autoAttachChildProcesses": true, + "skipFiles": ["/**", "**/node_modules/**"], + "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", + "args": ["run", "${relativeFile}"], + "smartStep": true, + "console": "integratedTerminal" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..e629c1cac6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "svelte"], + "svelte.enable-ts-plugin": true, + "vitest.include": ["**/*.spec.ts"], + "typescript.tsdk": "node_modules\\typescript\\lib", + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": false, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": false +} diff --git a/DEVELOPER.md b/DEVELOPER.md new file mode 100644 index 0000000000..847b0e5cd1 --- /dev/null +++ b/DEVELOPER.md @@ -0,0 +1,76 @@ +# AgnosUI + +A framework-agnostic widget library. + +This is a monorepo organised with multiple npm packages: + +- [`core`](core) contains the framework agnostic sources + +Then for each supported framework, a corresponding package is present with framework-specific code. Each of them contains the `lib` folder, with components using the `core`, and the `demo` folder, hosting the demo application. + +- [`angular`](angular) contains Angular sources, with the server running on port 4200 +- [`react`](react) contains React sources, with the server running on port 3000 +- [`svelte`](svelte) contains Svelte sources, with the server running on port 3001 + +Finally, two other npm packages are available for testing purposes: + +- [`base-po`](base-po) contains a base class for page objects when writing end-to-end tests with Playwright +- [`page-objects`](page-objects) contains page objects for each AgnosUI widget + +## Running AgnosUI locally + +If you want to play with AgnosUI on your own machine: + +- Clone the project +- Run `npm install` +- Use commands below + +## Commands + +Several commands are available in the different workspaces. In order to avoid to repeatedly enter the same thing for each workspace, the command will be run on every workspace directly. If needed, you can filter them by entering the workspace names. + +For example: + +- `npm run dev` will run the task `dev` on the workspaces `demo`, `angular`, `react` and `svelte`. +- `npm run dev demo svelte` will run the task `dev` on the workspaces `demo` and `svelte`. + +### Run the demos + +- `npm run dev` will run the servers in dev mode (workspaces : `demo`, `angular`, `react`, `svelte`). + +- `npm run dev angular` runs the Angular demo on http://localhost:4200 +- `npm run dev react` runs the React demo on http://localhost:3000 +- `npm run dev svelte` runs the Svelte demo on http://localhost:3001 + +### Preview + +The preview mode is used to run the servers that will serve a build version of the applications. + +- `npm run preview` (workspaces : `demo`, `angular`, `react`, `svelte`). + +### Unit tests + +Vitest is the test runner used to run the unit tests of the core sources + +- `npm run tdd`: run the TDD in watch mode (workspaces : `core`, `angular`, `eslint-plugin`). +- `npm run tdd:ui`: run the TDD in the watch mode, with a UI (workspaces : `core`, `eslint-plugin`) +- `npm run test:coverage`: run the unit tests with the coverage report (workspaces : `core`, `eslint-plugin`) + +It's also possible to filter test by their pathname. For example, `npm run tdd core rating` will run the tdd task of the `core` workspace, for the `rating` only, + +### End-to-end tests + +The end-to-end (e2e) tests are performed with Playwright. The setup is done to run the same specs on Chromium, Firefox and Webkit, on the three demos (Angular, React and Svelte) to ensure that everything works the same in each combination. + +You can use some specific keyworks to filter the tests: + +- `angular`, `react` or `svelte` will select a specific framework, +- `chromium`, `firefox` or `webkit` will select a specific browser, +- Any other word will filter the tests by filename. + +For example: + +- `npm run e2e`: run the full e2e suite for all the browsers, all the applications, +- `npm run e2e angular`: run all the specs for Angular in all browsers, +- `npm run e2e svelte chromium`: run all the specs for Svelte in Chromium, +- `npm run e2e firefox react select`: run all the specs with the 'select' filename, for React in Firefox, diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000000..d0ccbc5b03 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1 @@ +To be done... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..d01dc6fe00 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Amadeus s.a.s. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..83b40ff3c9 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# AgnosUI: A Versatile Frontend Widget Library for CSS Bootstrap Design + +## Introduction + +AgnosUI is a powerful library of widgets designed specifically for the [CSS Bootstrap design](https://getbootstrap.com/). Inspired by the success of [ng-bootstrap](https://ng-bootstrap.github.io/#/home), AgnosUI takes the concept a step further by offering widgets that can seamlessly integrate with any front-end framework of your choice. With support for popular frameworks like [Angular](https://angular.io/), [React](https://react.dev/), and [Svelte](https://svelte.dev/), AgnosUI allows you to effortlessly create consistent and visually appealing UI components across your projects. + +## Key Characteristics + +1. **Framework Agnostic Widgets**: AgnosUI's widget architecture revolves around a framework-agnostic core. Each widget is implemented in this core, focusing on its model (data) and the methods required to manipulate this data. This abstraction allows developers to create widgets independently of any specific framework, facilitating integration into various projects. + +2. **Extensive Framework Support**: AgnosUI currently offers support for three widely-used front-end frameworks: Angular, React, and Svelte. This diverse compatibility ensures that developers can leverage AgnosUI's widgets seamlessly across projects, irrespective of the chosen framework. + +3. **Adapters for Each Framework**: To achieve compatibility with different front-end frameworks, each widget in AgnosUI has an adapter for every supported framework. These adapters play a pivotal role in building the widget's UI by: + + - Constructing the appropriate markup based on the core data. + - Connecting user actions to the corresponding core methods. + - Listening for any change to the model and automatically triggering re-renders of the markup. + +4. **Flexible Widget Customization**: AgnosUI allows developers to configure and override any widget props at any point within the component subtree. This flexibility enables extensive customization possibilities, empowering developers to tailor the widgets to suit their specific project requirements. + +5. **Thorough Testing**: The core of AgnosUI undergoes comprehensive unit testing using [Vitest](https://vitest.dev/), ensuring its independence from any specific framework. Additionally, rigorous end-to-end tests are conducted with [Playwright](https://playwright.dev/) across different frameworks and browsers (Chromium, Firefox, Webkit). As the markup remains consistent for all frameworks, these tests are inherently framework-agnostic, guaranteeing robust and reliable widget functionalities. + +## Advantages + +1. **Consistent User Experience**: AgnosUI's adapter-based approach ensures a uniform user experience across all supported frameworks. Any fix or new feature implemented at the core level automatically propagate to all adapters, minimizing discrepancies between frameworks. + +2. **Functionality Assurance**: With a strong focus on testing, AgnosUI guarantees consistent functionalities between frameworks. This assurance is invaluable to developers, as it simplifies development and enables them to create widgets with confidence. + +## Getting Started + +To start using AgnosUI in your project, follow the instructions in the [Installation Guide](INSTALLATION.md). For detailed documentation on each widget and its usage, refer to the [Documentation](https://amadeusitgroup.github.io/AgnosUI/latest/). + +## Contributing + +We welcome contributions from the community to make AgnosUI even better. Please read our [Contribution Guidelines](DEVELOPER.md) to get started. + +## License + +AgnosUI is released under the [MIT License](LICENSE). + +--- diff --git a/angular/.eslintrc.json b/angular/.eslintrc.json new file mode 100644 index 0000000000..9f26efe88a --- /dev/null +++ b/angular/.eslintrc.json @@ -0,0 +1,47 @@ +{ + "extends": "../.eslintrc.json", + "overrides": [ + { + "files": ["*.ts"], + "parserOptions": { + "project": ["tsconfig.json"], + "createDefaultProgram": true + }, + "extends": ["plugin:@angular-eslint/recommended", "plugin:@angular-eslint/template/process-inline-templates"], + "rules": { + "@angular-eslint/no-host-metadata-property": "off", + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "lib", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "lib", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@angular-eslint/template/recommended"], + "rules": {} + }, + { + "files": ["**/karma.conf.js"], + "env": { + "browser": false, + "node": true + }, + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ] +} diff --git a/angular/angular.json b/angular/angular.json new file mode 100644 index 0000000000..17e9df39dd --- /dev/null +++ b/angular/angular.json @@ -0,0 +1,133 @@ +{ + "$schema": "../node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "", + "projects": { + "demo": { + "projectType": "application", + "schematics": { + "@schematics/angular:application": { + "strict": true + } + }, + "root": "demo", + "sourceRoot": "demo/src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/demo", + "index": "demo/src/index.html", + "main": "demo/src/main.ts", + "polyfills": "demo/src/polyfills.ts", + "tsConfig": "demo/tsconfig.app.json", + "assets": ["demo/src/favicon.ico", "demo/src/assets"], + "styles": ["demo/src/styles.css", "../common/demo.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "fileReplacements": [ + { + "replace": "demo/src/environments/environment.ts", + "with": "demo/src/environments/environment.prod.ts" + } + ], + "outputHashing": "all" + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "servePath": "/angular/samples", + "browserTarget": "demo:build:production" + }, + "development": { + "servePath": "/angular/samples", + "browserTarget": "demo:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "demo:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "watch": false, + "main": "demo/src/test.ts", + "polyfills": "demo/src/polyfills.ts", + "tsConfig": "demo/tsconfig.spec.json", + "karmaConfig": "demo/karma.conf.js", + "assets": ["demo/src/favicon.ico", "demo/src/assets"], + "styles": ["demo/src/styles.css"], + "scripts": [] + } + } + } + }, + "lib": { + "projectType": "library", + "root": "lib", + "sourceRoot": "lib/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "lib/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "lib/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "lib/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "watch": false, + "main": "lib/src/test.ts", + "tsConfig": "lib/tsconfig.spec.json", + "karmaConfig": "lib/karma.conf.js" + } + } + } + } + }, + "cli": { + "analytics": false + } +} diff --git a/angular/demo/.eslintrc.json b/angular/demo/.eslintrc.json new file mode 100644 index 0000000000..5d2a0e9d9a --- /dev/null +++ b/angular/demo/.eslintrc.json @@ -0,0 +1,34 @@ +{ + "extends": "../.eslintrc.json", + "overrides": [ + { + "files": ["*.ts"], + "parserOptions": { + "project": ["angular/demo/tsconfig.app.json", "angular/demo/tsconfig.spec.json"], + "createDefaultProgram": true + }, + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "rules": {} + } + ] +} diff --git a/angular/demo/karma.conf.js b/angular/demo/karma.conf.js new file mode 100644 index 0000000000..e2c54591ff --- /dev/null +++ b/angular/demo/karma.conf.js @@ -0,0 +1,50 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +const isCI = process.env.CI === 'true'; +process.env.CHROME_BIN = require('@playwright/test').chromium.executablePath(); + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + jasmine: { + // you can add configuration options for Jasmine here + // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html + // for example, you can disable the random execution with `random: false` + // or set a specific seed with `seed: 4321` + }, + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + jasmineHtmlReporter: { + suppressAll: true, // removes the duplicated traces + }, + coverageReporter: { + dir: require('path').join(__dirname, '../coverage/demo'), + subdir: '.', + reporters: [{type: 'html'}, {type: 'text-summary'}], + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: isCI ? ['ChromeHeadlessNoSandbox'] : ['Chrome'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox'], + }, + }, + singleRun: false, + restartOnFileChange: true, + }); +}; diff --git a/angular/demo/src/app/app.component.ts b/angular/demo/src/app/app.component.ts new file mode 100644 index 0000000000..223b5058a9 --- /dev/null +++ b/angular/demo/src/app/app.component.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; +import {RouterModule} from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterModule], + template: ` +
+ +
+ `, +}) +export class AppComponent {} diff --git a/angular/demo/src/app/app.routes.ts b/angular/demo/src/app/app.routes.ts new file mode 100644 index 0000000000..2285817a12 --- /dev/null +++ b/angular/demo/src/app/app.routes.ts @@ -0,0 +1,32 @@ +import type {Routes} from '@angular/router'; +import {links, LinksComponent} from './links.component'; + +const context = import.meta.webpackContext?.('./', { + regExp: /[^/]*\.route\.ts$/, +}); + +const componentRegExp = /samples\/([^/]*)\/([^/]*).route\.ts$/; +function replacePattern(webpackContext: __WebpackModuleApi.RequireContext) { + const directComponents: Record = {}; + const keys = webpackContext.keys(); + for (const key of keys) { + const matches = key.match(componentRegExp); + if (matches) { + directComponents[`${matches[1]}/${matches[2]}`.toLowerCase()] = key; + } + } + return directComponents; +} +const components = replacePattern(context!); + +export const ROUTES: Routes = [ + { + path: '', + pathMatch: 'full', + component: LinksComponent, + providers: [{provide: links, useValue: Object.keys(components)}], + }, + ...Object.entries(components).map(([path, component]) => { + return {path, loadComponent: async () => (await context!(component)).default}; + }), +]; diff --git a/angular/demo/src/app/links.component.ts b/angular/demo/src/app/links.component.ts new file mode 100644 index 0000000000..1785bad860 --- /dev/null +++ b/angular/demo/src/app/links.component.ts @@ -0,0 +1,20 @@ +import {CommonModule} from '@angular/common'; +import {Component, inject, InjectionToken} from '@angular/core'; + +export const links = new InjectionToken('app-links'); + +@Component({ + standalone: true, + imports: [CommonModule], + template: ` +

Samples:

+ + `, +}) +export class LinksComponent { + links = inject(links); +} diff --git a/angular/demo/src/app/samples/alert/config.route.ts b/angular/demo/src/app/samples/alert/config.route.ts new file mode 100644 index 0000000000..9e83a8a8eb --- /dev/null +++ b/angular/demo/src/app/samples/alert/config.route.ts @@ -0,0 +1,66 @@ +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import type {AlertComponent} from '@agnos-ui/angular'; +import {NgFor} from '@angular/common'; +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +export enum AlertStatus { + success = 'success', + info = 'info', + warning = 'warning', + danger = 'danger', + primary = 'primary', + secondary = 'secondary', + light = 'light', + dark = 'dark', +} + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, NgFor, FormsModule], + template: ` +
+
+
+
+ + +
+ + + + +
+ +
+
+ + +

Well done!

+

+ Aww yeah, you successfully read this important alert message. This example text is going to run a bit longer so that you can see how spacing + within an alert works with this kind of content. +

+
+

Whenever you need to, be sure to use margin utilities to keep things nice and tidy.

+
`, +}) +export default class ConfigAlertComponent { + styleList = Object.entries(AlertStatus).map((entry) => { + return { + value: entry[1], + label: entry[0], + }; + }); + + animationOnInit = true; + animation = true; + dismissible = true; + type = this.styleList[0].value; + + async showAlert(alert: AlertComponent) { + alert.api.open(); + } +} diff --git a/angular/demo/src/app/samples/alert/dynamic.route.ts b/angular/demo/src/app/samples/alert/dynamic.route.ts new file mode 100644 index 0000000000..57492f0e45 --- /dev/null +++ b/angular/demo/src/app/samples/alert/dynamic.route.ts @@ -0,0 +1,76 @@ +import {AlertComponent} from '@agnos-ui/angular'; +import type {AlertProps} from '@agnos-ui/angular'; +import {NgFor} from '@angular/common'; +import {Component, Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +class AlertContainerService { + alerts: Partial[] = []; + add(alert: Partial) { + this.alerts.push(alert); + } + + remove(type: Partial) { + this.alerts = this.alerts.filter((al) => al !== type); + } + + clear() { + this.alerts = []; + } +} + +@Component({ + selector: 'app-alert-child', + standalone: true, + imports: [AlertComponent, NgFor], + template: ` +
+ + +
+ `, +}) +class ChildComponent { + constructor(readonly alertContainerService: AlertContainerService) {} + + removeAlert(type: Partial) { + this.alertContainerService.remove(type); + } +} + +@Component({ + selector: 'app-alert-parent', + standalone: true, + imports: [ChildComponent], + template: ` + + + +
+
Alerts in the service: {{ alertContainerService.alerts.length }}
+ + `, +}) +export default class ParentComponent { + constructor(readonly alertContainerService: AlertContainerService) {} + + addError() { + this.alertContainerService.add({type: 'danger', slotDefault: 'Error', dismissible: true, animation: true}); + } + + addWarning() { + this.alertContainerService.add({type: 'warning', slotDefault: 'Warning', dismissible: true, animation: true}); + } + + addInfo() { + this.alertContainerService.add({type: 'info', slotDefault: 'Info', dismissible: true, animation: true}); + } +} diff --git a/angular/demo/src/app/samples/alert/generic.route.ts b/angular/demo/src/app/samples/alert/generic.route.ts new file mode 100644 index 0000000000..165da2c59c --- /dev/null +++ b/angular/demo/src/app/samples/alert/generic.route.ts @@ -0,0 +1,31 @@ +import {AgnosUIAngularModule, provideWidgetsConfig} from '@agnos-ui/angular'; +import {NgFor} from '@angular/common'; +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, NgFor, FormsModule], + providers: [ + provideWidgetsConfig((config) => { + config.alert = {...config.alert, dismissible: false}; + return config; + }), + ], + template: ` Simple primary alert + + Simple secondary alert + + Simple success alert + + Simple danger alert + + Simple warning alert + + Simple info alert + + Simple light alert + + Simple dark alert`, +}) +export default class GenericAlertComponent {} diff --git a/angular/demo/src/app/samples/alert/icon.route.ts b/angular/demo/src/app/samples/alert/icon.route.ts new file mode 100644 index 0000000000..4e664636e4 --- /dev/null +++ b/angular/demo/src/app/samples/alert/icon.route.ts @@ -0,0 +1,68 @@ +import biCheckCircleFill from '!raw-loader!bootstrap-icons/icons/check-circle-fill.svg'; +import biDashCircleFill from '!raw-loader!bootstrap-icons/icons/dash-circle-fill.svg'; +import biExclamationTriangleFill from '!raw-loader!bootstrap-icons/icons/exclamation-triangle-fill.svg'; +import biInfoCircleFill from '!raw-loader!bootstrap-icons/icons/info-circle-fill.svg'; +import biLightbulb from '!raw-loader!bootstrap-icons/icons/lightbulb.svg'; +import type {AlertComponent} from '@agnos-ui/angular'; +import {AgnosUIAngularModule, ComponentTemplate, provideWidgetsConfig, SlotDirective} from '@agnos-ui/angular'; +import {AsyncPipe, NgFor, NgIf} from '@angular/common'; +import {Component, inject, ViewChild} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {DomSanitizer} from '@angular/platform-browser'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, NgFor, FormsModule], + providers: [ + provideWidgetsConfig((config) => { + config.alert = {...config.alert, dismissible: false, slotStructure: new ComponentTemplate(AlertIconComponent, 'iconDemo')}; + return config; + }), + ], + template: ` + Alert success with a customisable icon + Alert warning with a customisable icon + Alert danger with a customisable icon + Alert info with a customisable icon + Alert light with a customisable icon + `, +}) +export default class IconAlertComponent { + async showAlert(alert: AlertComponent) { + alert.api.open(); + } +} + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, NgIf, AsyncPipe, NgFor, SlotDirective], + providers: [SlotDirective], + template: ` + + +
+ + +
+
+ `, +}) +export class AlertIconComponent { + sanitizer = inject(DomSanitizer); + + @ViewChild('iconDemo', {static: true}) iconDemo: any; + + typeIcon: Record = { + success: biCheckCircleFill, + info: biInfoCircleFill, + warning: biExclamationTriangleFill, + danger: biDashCircleFill, + light: biLightbulb, + }; +} diff --git a/angular/demo/src/app/samples/alert/playground.route.ts b/angular/demo/src/app/samples/alert/playground.route.ts new file mode 100644 index 0000000000..4ccb041de8 --- /dev/null +++ b/angular/demo/src/app/samples/alert/playground.route.ts @@ -0,0 +1,23 @@ +import type {AlertComponent} from '@agnos-ui/angular'; +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {getAlertDefaultConfig} from '@agnos-ui/core'; +import {Component, ViewChild} from '@angular/core'; +import {getUndefinedValues, hashChangeHook, provideHashConfig} from '../../utils'; + +const undefinedConfig = getUndefinedValues(getAlertDefaultConfig()); + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + providers: provideHashConfig('alert'), + template: ``, +}) +export default class PlaygroundComponent { + @ViewChild('widget') widget: AlertComponent; + + constructor() { + hashChangeHook((props) => { + this.widget?._widget.patch({...undefinedConfig, ...props}); + }); + } +} diff --git a/angular/demo/src/app/samples/alert/raw-loader.d.ts b/angular/demo/src/app/samples/alert/raw-loader.d.ts new file mode 100644 index 0000000000..74a0a28757 --- /dev/null +++ b/angular/demo/src/app/samples/alert/raw-loader.d.ts @@ -0,0 +1,4 @@ +declare module '!raw-loader!*' { + const contents: string; + export = contents; +} diff --git a/angular/demo/src/app/samples/focustrack/focustrack.route.ts b/angular/demo/src/app/samples/focustrack/focustrack.route.ts new file mode 100644 index 0000000000..c4dc227772 --- /dev/null +++ b/angular/demo/src/app/samples/focustrack/focustrack.route.ts @@ -0,0 +1,52 @@ +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {activeElement$, createHasFocus} from '@agnos-ui/core'; +import {CommonModule} from '@angular/common'; +import type {OnDestroy} from '@angular/core'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, CommonModule], + template: ` +
+
+
Container
+
+
+
+
+
+ + +
+ + + +
+ `, +}) +export default class FocustrackComponent implements OnDestroy { + protected hasFocusApi; + protected activeElements: any[] = []; + protected activeElementsJson = ''; + private unsubscribe = () => { + // empty + }; + + constructor() { + this.hasFocusApi = createHasFocus(); + this.unsubscribe = activeElement$.subscribe((activeElement) => { + this.activeElements.push({tagName: activeElement?.tagName.toLowerCase(), id: activeElement?.id || undefined}); + this.activeElementsJson = JSON.stringify(this.activeElements); + }); + } + + ngOnDestroy(): void { + this.unsubscribe(); + } + + clear() { + this.activeElements = []; + this.activeElementsJson = JSON.stringify([]); + } +} diff --git a/angular/demo/src/app/samples/modal/default.route.ts b/angular/demo/src/app/samples/modal/default.route.ts new file mode 100644 index 0000000000..b03d806ea1 --- /dev/null +++ b/angular/demo/src/app/samples/modal/default.route.ts @@ -0,0 +1,37 @@ +import type {ModalComponent} from '@agnos-ui/angular'; +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {modalCloseButtonClick, modalOutsideClick} from '@agnos-ui/core'; +import {CommonModule} from '@angular/common'; +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, CommonModule, FormsModule], + template: ` + +
{{ message }}
+ + Do you want to save your changes? + + + + + + `, +}) +export default class DefaultModalComponent { + message = ''; + + async show(modal: ModalComponent) { + this.message = ''; + const result = await modal.api.open(); + if (result === modalCloseButtonClick) { + this.message = 'You clicked on the close button'; + } else if (result === modalOutsideClick) { + this.message = 'You clicked outside the modal'; + } else { + this.message = `You answered the question with "${result ? 'Yes' : 'No'}"`; + } + } +} diff --git a/angular/demo/src/app/samples/modal/playground.route.ts b/angular/demo/src/app/samples/modal/playground.route.ts new file mode 100644 index 0000000000..38dac8cd0e --- /dev/null +++ b/angular/demo/src/app/samples/modal/playground.route.ts @@ -0,0 +1,23 @@ +import type {ModalComponent} from '@agnos-ui/angular'; +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {getModalDefaultConfig} from '@agnos-ui/core'; +import {Component, ViewChild} from '@angular/core'; +import {getUndefinedValues, hashChangeHook, provideHashConfig} from '../../utils'; + +const undefinedConfig = getUndefinedValues(getModalDefaultConfig()); + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + providers: provideHashConfig('modal'), + template: ``, +}) +export default class PlaygroundComponent { + @ViewChild('widget') widget: ModalComponent; + + constructor() { + hashChangeHook((props) => { + this.widget?._widget.patch({...undefinedConfig, ...props}); + }); + } +} diff --git a/angular/demo/src/app/samples/modal/stack.route.ts b/angular/demo/src/app/samples/modal/stack.route.ts new file mode 100644 index 0000000000..82b17a54cb --- /dev/null +++ b/angular/demo/src/app/samples/modal/stack.route.ts @@ -0,0 +1,29 @@ +import {AgnosUIAngularModule, ModalService} from '@agnos-ui/angular'; +import {CommonModule} from '@angular/common'; +import {Component, inject} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, CommonModule, FormsModule], + template: ` + + + This is a modal

+
+ +
+ +
+ `, +}) +export default class ModalDemoComponent { + modalService = inject(ModalService); +} diff --git a/angular/demo/src/app/samples/pagination/config.route.ts b/angular/demo/src/app/samples/pagination/config.route.ts new file mode 100644 index 0000000000..46b91cdfa8 --- /dev/null +++ b/angular/demo/src/app/samples/pagination/config.route.ts @@ -0,0 +1,184 @@ +import type {PaginationContext, PaginationProps} from '@agnos-ui/angular'; +import {AgnosUIAngularModule, SlotDirective, injectWidgetsConfig, provideWidgetsConfig} from '@agnos-ui/angular'; +import {AsyncPipe, NgFor, NgIf} from '@angular/common'; +import {Component} from '@angular/core'; + +const FILTER_PAG_REGEX = /[^0-9]/g; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, NgIf, AsyncPipe, NgFor, SlotDirective], + providers: [provideWidgetsConfig()], + template: ` + +
  • +
    + + + of {{ state.pages.length }} +
    +
  • +
    + +
  • + + + + + + {{ state.activeLabel }} + +
  • +
    + +
    + Disabled: +
    + + +
    +
    + Collection Size: +
    + + + + +
    +
    + className: +
    + + + +
    +
    + slotPages: +
    + + + +
    +
    + `, +}) +export default class PaginationConfigComponent { + page = 3; + widgetsConfig$ = injectWidgetsConfig(); + + updatePaginationConfig(change: Partial) { + this.widgetsConfig$.update((value) => ({...value, pagination: {...value.pagination, ...change}})); + } + + formatInput(input: HTMLInputElement) { + input.value = input.value.replace(FILTER_PAG_REGEX, ''); + } + handleOnBlur(e: any, input: HTMLInputElement, widget: PaginationContext['widget']) { + const value = e.target.value; + const intValue = parseInt(value); + widget.actions.select(intValue); + input.value = widget.stores.page$().toString(); + } + handleKeyDownEnter(e: any, input: HTMLInputElement, widget: PaginationContext['widget']) { + this.handleOnBlur(e, input, widget); + } +} diff --git a/angular/demo/src/app/samples/pagination/default.route.ts b/angular/demo/src/app/samples/pagination/default.route.ts new file mode 100644 index 0000000000..bbf48391f0 --- /dev/null +++ b/angular/demo/src/app/samples/pagination/default.route.ts @@ -0,0 +1,16 @@ +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + template: ` + +
    + Current page: {{ page }} +
    + `, +}) +export default class DefaultPaginationComponent { + page = 4; +} diff --git a/angular/demo/src/app/samples/pagination/pagination.route.ts b/angular/demo/src/app/samples/pagination/pagination.route.ts new file mode 100644 index 0000000000..dfe1be029b --- /dev/null +++ b/angular/demo/src/app/samples/pagination/pagination.route.ts @@ -0,0 +1,96 @@ +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {ngBootstrapPagination} from '@agnos-ui/core'; +import {AsyncPipe, NgIf} from '@angular/common'; +import {Component} from '@angular/core'; +import {ReactiveFormsModule} from '@angular/forms'; + +const FILTER_PAG_REGEX = /[^0-9]/g; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, ReactiveFormsModule, NgIf, AsyncPipe], + template: ` +

    Default pagination:

    + + +

    Disabled pagination:

    + + +

    No direction links:

    + + +

    With boundary links:

    + + +

    Restricted size, no rotation:

    + + +
    Current page: {{ page }}
    + +

    Restricted size with rotation:

    + + +

    Restricted size with rotation and no ellipses:

    + + +
    +

    Pagination Size

    + + + + +
    + +
    Current page: {{ currentPage }}
    + +

    A pagination with customized links:

    + + Prev + Next + {{ getPageSymbol(displayedPage) }} + +
    + +

    A pagination with customized pages:

    + + +
  • +
    + + + of {{ state.pages.length }} +
    +
  • +
    +
    +
    + `, +}) +export default class PaginationComponent { + page = 4; + customPage = 4; + currentPage = 10; + pageAlone = 4; + pagesFactory = ngBootstrapPagination; + + getPageSymbol(current: number) { + return ['A', 'B', 'C', 'D', 'E', 'F', 'G'][current - 1]; + } + + formatInput(input: HTMLInputElement) { + input.value = input.value.replace(FILTER_PAG_REGEX, ''); + } +} diff --git a/angular/demo/src/app/samples/pagination/playground.route.ts b/angular/demo/src/app/samples/pagination/playground.route.ts new file mode 100644 index 0000000000..6d60da7df6 --- /dev/null +++ b/angular/demo/src/app/samples/pagination/playground.route.ts @@ -0,0 +1,23 @@ +import type {PaginationComponent} from '@agnos-ui/angular'; +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {getPaginationDefaultConfig} from '@agnos-ui/core'; +import {Component, ViewChild} from '@angular/core'; +import {getUndefinedValues, hashChangeHook, provideHashConfig} from '../../utils'; + +const undefinedConfig = getUndefinedValues(getPaginationDefaultConfig()); + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + providers: provideHashConfig('pagination'), + template: ``, +}) +export default class PlaygroundComponent { + @ViewChild('widget') widget: PaginationComponent; + + constructor() { + hashChangeHook((props) => { + this.widget?._widget.patch({...undefinedConfig, ...props}); + }); + } +} diff --git a/angular/demo/src/app/samples/rating/config.route.ts b/angular/demo/src/app/samples/rating/config.route.ts new file mode 100644 index 0000000000..6bf5255802 --- /dev/null +++ b/angular/demo/src/app/samples/rating/config.route.ts @@ -0,0 +1,125 @@ +import type {RatingProps} from '@agnos-ui/angular'; +import {AgnosUIAngularModule, injectWidgetsConfig, provideWidgetsConfig} from '@agnos-ui/angular'; +import {AsyncPipe} from '@angular/common'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, AsyncPipe], + providers: [provideWidgetsConfig()], + template: ` + + + + +
    + Disabled: +
    + + +
    +
    + maxRating: +
    + + + + +
    +
    + className: +
    + + + +
    +
    + slotStar: +
    + + + +
    +
    + `, +}) +export default class RatingConfigComponent { + rating = 3; + widgetsConfig$ = injectWidgetsConfig(); + + updateRatingConfig(change: Partial) { + this.widgetsConfig$.update((value) => ({...value, rating: {...value.rating, ...change}})); + } +} diff --git a/angular/demo/src/app/samples/rating/customTemplate.route.ts b/angular/demo/src/app/samples/rating/customTemplate.route.ts new file mode 100644 index 0000000000..812fe23ad6 --- /dev/null +++ b/angular/demo/src/app/samples/rating/customTemplate.route.ts @@ -0,0 +1,15 @@ +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + template: ` + + + + + + `, +}) +export default class CustomTemplateComponent {} diff --git a/angular/demo/src/app/samples/rating/default.route.ts b/angular/demo/src/app/samples/rating/default.route.ts new file mode 100644 index 0000000000..5b4667f469 --- /dev/null +++ b/angular/demo/src/app/samples/rating/default.route.ts @@ -0,0 +1,22 @@ +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + template: ` + +
    + Current rate: {{ rating }}
    + Hovered: {{ hovered }}
    + Left: {{ left }} +
    + `, +}) +export default class DefaultRatingComponent { + rating = 3; + hovered = 0; + left = 0; +} diff --git a/angular/demo/src/app/samples/rating/form.route.ts b/angular/demo/src/app/samples/rating/form.route.ts new file mode 100644 index 0000000000..500c5354e2 --- /dev/null +++ b/angular/demo/src/app/samples/rating/form.route.ts @@ -0,0 +1,33 @@ +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {NgIf} from '@angular/common'; +import {Component} from '@angular/core'; +import {FormControl, ReactiveFormsModule, Validators} from '@angular/forms'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, ReactiveFormsModule, NgIf], + template: ` +
    + +
    +
    Thanks!
    +
    Please rate us
    +
    +
    Model: {{ ctrl.value }}
    + + + `, +}) +export default class FormRatingComponent { + ctrl = new FormControl(0, Validators.min(1)); + + toggle() { + if (this.ctrl.disabled) { + this.ctrl.enable(); + } else { + this.ctrl.disable(); + } + } +} diff --git a/angular/demo/src/app/samples/rating/playground.route.ts b/angular/demo/src/app/samples/rating/playground.route.ts new file mode 100644 index 0000000000..48a29cffc1 --- /dev/null +++ b/angular/demo/src/app/samples/rating/playground.route.ts @@ -0,0 +1,23 @@ +import type {RatingComponent} from '@agnos-ui/angular'; +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {getRatingDefaultConfig} from '@agnos-ui/core'; +import {Component, ViewChild} from '@angular/core'; +import {getUndefinedValues, hashChangeHook, provideHashConfig} from '../../utils'; + +const undefinedConfig = getUndefinedValues(getRatingDefaultConfig()); + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + providers: provideHashConfig('rating'), + template: ``, +}) +export default class PlaygroundComponent { + @ViewChild('widget') widget: RatingComponent; + + constructor() { + hashChangeHook((props) => { + this.widget?._widget.patch({...undefinedConfig, ...props}); + }); + } +} diff --git a/angular/demo/src/app/samples/rating/readonly.route.ts b/angular/demo/src/app/samples/rating/readonly.route.ts new file mode 100644 index 0000000000..eb4dae9b4b --- /dev/null +++ b/angular/demo/src/app/samples/rating/readonly.route.ts @@ -0,0 +1,14 @@ +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import {Component} from '@angular/core'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule], + template: ` + + + + + `, +}) +export default class ReadonlyRatingComponent {} diff --git a/angular/demo/src/app/samples/select/select.route.ts b/angular/demo/src/app/samples/select/select.route.ts new file mode 100644 index 0000000000..da1734095b --- /dev/null +++ b/angular/demo/src/app/samples/select/select.route.ts @@ -0,0 +1,48 @@ +import {AgnosUIAngularModule, injectWidgetsConfig, provideWidgetsConfig} from '@agnos-ui/angular'; +import {CommonModule} from '@angular/common'; +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +@Component({ + standalone: true, + imports: [AgnosUIAngularModule, CommonModule, FormsModule], + providers: [provideWidgetsConfig()], + template: ` +

    Multiselect example

    +
    + + +
    +
    + Default config
    + +
    + +
    + `, +}) +export default class SelectComponent { + items = ['Action 1', 'Action 2', 'Action 3', 'Other 1', 'Other 2', 'Other 3']; + + filterText: string | undefined; + + widgetsConfig$ = injectWidgetsConfig(); + + constructor() { + const params = location.hash.split('?')[1]; + const url = new URL(params ? `?${params}` : '', location.href); + this.widgetsConfig$.set({ + select: { + filterText: url.searchParams.get('filterText') ?? '', + }, + }); + } +} diff --git a/angular/demo/src/app/samples/transition/innerComponent.component.ts b/angular/demo/src/app/samples/transition/innerComponent.component.ts new file mode 100644 index 0000000000..30f0feb1fb --- /dev/null +++ b/angular/demo/src/app/samples/transition/innerComponent.component.ts @@ -0,0 +1,131 @@ +import type {OnDestroy} from '@angular/core'; +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {AgnosUIAngularModule} from '@agnos-ui/angular'; +import type {TransitionFn} from '@agnos-ui/core'; +import {bootstrap, createTransition} from '@agnos-ui/core'; +import {writable} from '@amadeus-it-group/tansu'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; + +const paramTransition$ = writable(bootstrap.collapseVerticalTransition); +const paramAnimation$ = writable(true); +const paramAnimationOnInit$ = writable(false); +const paramVisible$ = writable(true); +const paramRemoveFromDom$ = writable(true); + +@Component({ + standalone: true, + selector: 'app-transition-inner', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [AgnosUIAngularModule, FormsModule, CommonModule], + template: ` +
    +

    + Transition: + + + +

    + + + + + + + + +
      +
    • visible = {{ transition.stores.visible$ | async }}
    • +
    • transitioning = {{ transition.stores.transitioning$ | async }}
    • +
    • shown = {{ transition.stores.shown$ | async }}
    • +
    • hidden = {{ transition.stores.hidden$ | async }}
    • +
    + +
    +
    +
    You can collapse this card by clicking Toggle
    +
    +
    +
    + `, +}) +export class InnerComponent implements OnDestroy { + bootstrap = bootstrap; + paramTransition$ = paramTransition$; + paramAnimation$ = paramAnimation$; + paramAnimationOnInit$ = paramAnimationOnInit$; + paramVisible$ = paramVisible$; + paramRemoveFromDom$ = paramRemoveFromDom$; + transition = createTransition({ + animationOnInit: paramAnimationOnInit$(), + animation: paramAnimation$(), + visible: paramVisible$(), + }); + private _unSyncParamVisible = this.transition.stores.visible$.subscribe(paramVisible$.set); + + changeTransition(newTransition: TransitionFn) { + // Make sure the element is removed from the DOM + // so that it does not keep state from the previous transition + this.transition.api.toggle(false, false); + paramRemoveFromDom$.set(true); + paramTransition$.set(newTransition); + } + + ngOnDestroy(): void { + this._unSyncParamVisible(); + } +} diff --git a/angular/demo/src/app/samples/transition/transition.route.ts b/angular/demo/src/app/samples/transition/transition.route.ts new file mode 100644 index 0000000000..087c733c27 --- /dev/null +++ b/angular/demo/src/app/samples/transition/transition.route.ts @@ -0,0 +1,21 @@ +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {InnerComponent} from './innerComponent.component'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, InnerComponent], + template: ` +

    Transition example

    +
    + + + + +
    + `, +}) +export default class TransitionComponent { + showComponent = true; +} diff --git a/angular/demo/src/app/utils.ts b/angular/demo/src/app/utils.ts new file mode 100644 index 0000000000..2d60ca8179 --- /dev/null +++ b/angular/demo/src/app/utils.ts @@ -0,0 +1,62 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import {computed, get} from '@amadeus-it-group/tansu'; +import type {WidgetsConfig} from '@agnos-ui/angular'; +import {provideWidgetsConfig} from '@agnos-ui/angular'; +import type {Provider} from '@angular/core'; +import {InjectionToken, effect, inject} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {ActivatedRoute} from '@angular/router'; + +function getJsonHash(json: string) { + const {config = {}, props = {}} = JSON.parse(json ?? '{}'); + return {config, props}; +} + +export const hashConfigToken = new InjectionToken>>('hashconfig'); + +export function provideHashConfig(widgetName: keyof WidgetsConfig): Provider[] { + return [ + { + provide: hashConfigToken, + useFactory: () => { + const activeRoute = inject(ActivatedRoute); + return computed(() => { + const fragment = get(activeRoute.fragment); + return getJsonHash(fragment == null ? '{}' : fragment); + }); + }, + }, + provideWidgetsConfig((parentConfig) => { + parentConfig[widgetName] = inject(hashConfigToken)().config; + return parentConfig; + }), + ]; +} + +export function hashChangeHook(propsCallback: (props: any) => void) { + const hashConfig$ = toSignal(inject(hashConfigToken), {requireSync: true}); + let lastProps; + + async function callPropsCallback(props: any) { + lastProps = props; + await 0; + if (lastProps === props) { + propsCallback(props); + } + } + + effect(() => { + callPropsCallback(hashConfig$().props); + }); +} + +/** + * Transform all the values of a json object to + */ +export function getUndefinedValues(defaultConfig: T) { + const undefinedObj: Record = {}; + for (const key of Object.keys(defaultConfig)) { + undefinedObj[key] = undefined; + } + return undefinedObj as Record; +} diff --git a/angular/demo/src/assets/.gitkeep b/angular/demo/src/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/angular/demo/src/assets/logo.svg b/angular/demo/src/assets/logo.svg new file mode 100644 index 0000000000..010e9a863c --- /dev/null +++ b/angular/demo/src/assets/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/angular/demo/src/environments/environment.prod.ts b/angular/demo/src/environments/environment.prod.ts new file mode 100644 index 0000000000..d65fc9d9b2 --- /dev/null +++ b/angular/demo/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: true, +}; diff --git a/angular/demo/src/environments/environment.ts b/angular/demo/src/environments/environment.ts new file mode 100644 index 0000000000..d531fcbf6f --- /dev/null +++ b/angular/demo/src/environments/environment.ts @@ -0,0 +1,16 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// `ng build` replaces `environment.ts` with `environment.prod.ts`. +// The list of file replacements can be found in `angular.json`. + +export const environment = { + production: false, +}; + +/* + * For easier debugging in development mode, you can import the following file + * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. + * + * This import should be commented out in production mode because it will have a negative impact + * on performance if an error is thrown. + */ +// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/angular/demo/src/favicon.ico b/angular/demo/src/favicon.ico new file mode 100644 index 0000000000..997406ad22 Binary files /dev/null and b/angular/demo/src/favicon.ico differ diff --git a/angular/demo/src/index.html b/angular/demo/src/index.html new file mode 100644 index 0000000000..d9fc79e928 --- /dev/null +++ b/angular/demo/src/index.html @@ -0,0 +1,12 @@ + + + + + + Demo + + + + + + diff --git a/angular/demo/src/main.ts b/angular/demo/src/main.ts new file mode 100644 index 0000000000..65a71c8a4d --- /dev/null +++ b/angular/demo/src/main.ts @@ -0,0 +1,16 @@ +import {enableProdMode} from '@angular/core'; + +import {HashLocationStrategy, LocationStrategy} from '@angular/common'; +import {bootstrapApplication} from '@angular/platform-browser'; +import {provideRouter} from '@angular/router'; +import {AppComponent} from './app/app.component'; +import {ROUTES} from './app/app.routes'; +import {environment} from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +bootstrapApplication(AppComponent, { + providers: [provideRouter(ROUTES), {provide: LocationStrategy, useClass: HashLocationStrategy}], +}).catch((err) => console.error(err)); diff --git a/angular/demo/src/polyfills.ts b/angular/demo/src/polyfills.ts new file mode 100644 index 0000000000..e4555ed11f --- /dev/null +++ b/angular/demo/src/polyfills.ts @@ -0,0 +1,52 @@ +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes recent versions of Safari, Chrome (including + * Opera), Edge on the desktop, and iOS and Chrome on mobile. + * + * Learn more in https://angular.io/guide/browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + * because those flags need to be set before `zone.js` being loaded, and webpack + * will put import in the top of bundle, so user need to create a separate file + * in this directory (for example: zone-flags.ts), and put the following flags + * into that file, and then add the following code before importing zone.js. + * import './zone-flags'; + * + * The flags allowed in zone-flags.ts are listed here. + * + * The following flags will work for all browsers. + * + * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + * + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + * + * (window as any).__Zone_enable_cross_context_check = true; + * + */ + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js'; // Included with Angular CLI. + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/angular/demo/src/styles.css b/angular/demo/src/styles.css new file mode 100644 index 0000000000..9dca10dc43 --- /dev/null +++ b/angular/demo/src/styles.css @@ -0,0 +1,3 @@ +/* You can add global styles to this file, and also import other style files */ + +@import 'bootstrap/dist/css/bootstrap.css'; diff --git a/angular/demo/src/test.ts b/angular/demo/src/test.ts new file mode 100644 index 0000000000..bdbddfe24f --- /dev/null +++ b/angular/demo/src/test.ts @@ -0,0 +1,8 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js/testing'; +import {getTestBed} from '@angular/core/testing'; +import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); diff --git a/angular/demo/tsconfig.app.json b/angular/demo/tsconfig.app.json new file mode 100644 index 0000000000..1e2c717ad9 --- /dev/null +++ b/angular/demo/tsconfig.app.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "types": ["@types/webpack-env"] + }, + "files": ["src/main.ts", "src/polyfills.ts"], + "include": ["src/**/*.d.ts", "src/app/samples/**/*.route.ts"] +} diff --git a/angular/demo/tsconfig.spec.json b/angular/demo/tsconfig.spec.json new file mode 100644 index 0000000000..ddd26289d0 --- /dev/null +++ b/angular/demo/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "types": ["jasmine"] + }, + "files": ["src/test.ts", "src/polyfills.ts"], + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/angular/lib/.eslintrc.json b/angular/lib/.eslintrc.json new file mode 100644 index 0000000000..d08c1f516c --- /dev/null +++ b/angular/lib/.eslintrc.json @@ -0,0 +1,37 @@ +{ + "extends": "../.eslintrc.json", + "overrides": [ + { + "files": ["*.ts"], + "parserOptions": { + "project": ["angular/lib/tsconfig.lib.dev.json", "angular/lib/tsconfig.spec.json"], + "createDefaultProgram": true + }, + "rules": { + "@angular-eslint/directive-selector": [ + "warn", + { + "type": "attribute", + "prefix": "au", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "warn", + { + "type": "element", + "prefix": "au", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "rules": {} + } + ], + "rules": { + "@agnos-ui/angular-check-props": "error" + } +} diff --git a/angular/lib/README.md b/angular/lib/README.md new file mode 100644 index 0000000000..7c689d1db2 --- /dev/null +++ b/angular/lib/README.md @@ -0,0 +1,15 @@ +# @agnos-ui/angular + +[![npm](https://img.shields.io/npm/v/@agnos-ui/angular)](https://www.npmjs.com/package/@agnos-ui/angular) + +[Bootstrap](https://getbootstrap.com/)-based widget library for [Angular](https://angular.io/). + +## Installation + +```sh +npm install @agnos-ui/angular +``` + +## Usage + +Please check [our demo site](https://amadeusitgroup.github.io/AgnosUI/latest/) to see all the available widgets and how to use them. diff --git a/angular/lib/karma.conf.js b/angular/lib/karma.conf.js new file mode 100644 index 0000000000..e5354231cf --- /dev/null +++ b/angular/lib/karma.conf.js @@ -0,0 +1,50 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +const isCI = process.env.CI === 'true'; +process.env.CHROME_BIN = require('@playwright/test').chromium.executablePath(); + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + jasmine: { + // you can add configuration options for Jasmine here + // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html + // for example, you can disable the random execution with `random: false` + // or set a specific seed with `seed: 4321` + }, + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + jasmineHtmlReporter: { + suppressAll: true, // removes the duplicated traces + }, + coverageReporter: { + dir: require('path').join(__dirname, '../coverage/agnos-ui-angular'), + subdir: '.', + reporters: [{type: 'html'}, {type: 'text-summary'}], + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: isCI ? ['ChromeHeadlessNoSandbox'] : ['Chrome'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox'], + }, + }, + singleRun: false, + restartOnFileChange: true, + }); +}; diff --git a/angular/lib/ng-package.json b/angular/lib/ng-package.json new file mode 100644 index 0000000000..061b303ec2 --- /dev/null +++ b/angular/lib/ng-package.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../dist/lib", + "allowedNonPeerDependencies": ["@agnos-ui/core", "@amadeus-it-group/tansu"], + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/angular/lib/package.json b/angular/lib/package.json new file mode 100644 index 0000000000..90d898f348 --- /dev/null +++ b/angular/lib/package.json @@ -0,0 +1,31 @@ +{ + "name": "@agnos-ui/angular", + "description": "Bootstrap-based widget library for Angular.", + "homepage": "https://amadeusitgroup.github.io/AgnosUI/latest/", + "keywords": [ + "angular", + "bootstrap", + "components", + "widgets", + "alert", + "modal", + "pagination", + "rating" + ], + "peerDependencies": { + "@angular/common": "^16.1.3", + "@angular/core": "^16.1.3" + }, + "dependencies": { + "@agnos-ui/core": "", + "@amadeus-it-group/tansu": "*", + "tslib": "^2.6.0" + }, + "license": "MIT", + "bugs": "https://github.com/AmadeusITGroup/AgnosUI/issues", + "repository": { + "type": "git", + "url": "https://github.com/AmadeusITGroup/AgnosUI.git", + "directory": "angular/lib" + } +} diff --git a/angular/lib/src/lib/agnos-ui-angular.module.ts b/angular/lib/src/lib/agnos-ui-angular.module.ts new file mode 100644 index 0000000000..ed9c0f749a --- /dev/null +++ b/angular/lib/src/lib/agnos-ui-angular.module.ts @@ -0,0 +1,54 @@ +import {NgModule} from '@angular/core'; +import { + ModalBodyDirective, + ModalComponent, + ModalFooterDirective, + ModalHeaderDirective, + ModalStructureDirective, + ModalTitleDirective, +} from './modal/modal.component'; +import { + PaginationComponent, + PaginationEllipsisDirective, + PaginationFirstDirective, + PaginationLastDirective, + PaginationNextDirective, + PaginationNumberDirective, + PaginationPagesDirective, + PaginationPreviousDirective, +} from './pagination/pagination.component'; +import {RatingComponent, RatingStarDirective} from './rating/rating.component'; +import {SelectComponent} from './select/select.component'; +import {UseDirective} from './transition/use.directive'; +import {SlotDirective} from './slot.directive'; +import {AlertComponent} from './alert/alert.component'; + +const components = [ + SlotDirective, + SelectComponent, + UseDirective, + RatingComponent, + RatingStarDirective, + PaginationComponent, + PaginationEllipsisDirective, + PaginationFirstDirective, + PaginationLastDirective, + PaginationNextDirective, + PaginationNumberDirective, + PaginationPreviousDirective, + PaginationPagesDirective, + ModalComponent, + ModalStructureDirective, + ModalHeaderDirective, + ModalTitleDirective, + ModalBodyDirective, + ModalFooterDirective, + AlertComponent, +]; + +@NgModule({ + declarations: [], + imports: components, + exports: components, +}) +export class AgnosUIAngularModule {} diff --git a/angular/lib/src/lib/alert/alert.component.ts b/angular/lib/src/lib/alert/alert.component.ts new file mode 100644 index 0000000000..565a97774c --- /dev/null +++ b/angular/lib/src/lib/alert/alert.component.ts @@ -0,0 +1,184 @@ +import {writable} from '@amadeus-it-group/tansu'; +import type {AlertContext as AlertCoreContext, TransitionFn, WidgetProps, WidgetState} from '@agnos-ui/core'; +import {createAlert, toSlotContextWidget} from '@agnos-ui/core'; +import {NgIf} from '@angular/common'; +import type {AfterContentChecked, OnChanges, Signal, SimpleChanges} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + Directive, + EventEmitter, + inject, + Input, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import type {AdaptSlotContentProps, AdaptWidgetSlots, SlotContent} from '../slot.directive'; +import {callWidgetFactory, ComponentTemplate, SlotDirective} from '../slot.directive'; +import {SlotDefaultDirective} from '../slotDefault.directive'; +import {UseDirective} from '../transition/use.directive'; +import {patchSimpleChanges} from '../utils'; + +export type AlertWidget = AdaptWidgetSlots>; +export type AlertState = WidgetState; +export type AlertProps = WidgetProps; + +export type AlertContext = AdaptSlotContentProps; + +@Directive({selector: 'ng-template[auAlertBody]', standalone: true}) +export class AlertBodyDirective { + public templateRef = inject(TemplateRef>); + static ngTemplateContextGuard(dir: AlertBodyDirective, context: unknown): context is AlertCoreContext { + return true; + } +} + +@Directive({selector: 'ng-template[auAlertStructure]', standalone: true}) +export class AlertStructureDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: AlertStructureDirective, context: unknown): context is AlertCoreContext { + return true; + } +} +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIf, SlotDirective, AlertStructureDirective], + template: ` +
    + +
    + +
    `, +}) +export class AlertDefaultSlotsComponent { + @ViewChild('structure', {static: true}) structure: TemplateRef; +} + +export const alertDefaultSlotStructure = new ComponentTemplate(AlertDefaultSlotsComponent, 'structure'); + +const defaultConfig: Partial = { + slotStructure: alertDefaultSlotStructure, +}; + +@Component({ + selector: 'au-alert', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIf, SlotDirective, UseDirective, SlotDefaultDirective], + template: ` + + + + `, +}) +export class AlertComponent implements OnChanges, AfterContentChecked { + /** + * Type of the alert. + * The are the following types: 'success', 'info', 'warning', 'danger', 'primary', 'secondary', 'light' and 'dark'. + */ + @Input() + type: string | undefined; + + /** + * If `true`, alert can be dismissed by the user. + * The close button (×) will be displayed and you can be notified of the event with the (close) output. + */ + @Input() + dismissible: boolean | undefined; + + /** + * The transition function will be executed when the alert is displayed or hidden. + * + * Depending on the value of AlertProps.animationOnInit, the animation can be optionally skipped during the showing process. + */ + @Input() + transition: TransitionFn | undefined; + + /** + * If `true` the alert is visible to the user + */ + @Input() + visible: boolean | undefined; + + /** + * If `true`, alert opening will be animated. + * + * Animation is triggered when the `.open()` function is called + * or the visible prop is changed + */ + @Input() + animationOnInit: boolean | undefined; + + /** + * If `true`, alert closing will be animated. + * + * Animation is triggered when clicked on the close button (×), + * via the `.close()` function or the visible prop is changed + */ + @Input() + animation: boolean | undefined; + + /** + * Accessibility close button label + */ + @Input() ariaCloseButtonLabel: string | undefined; + + @Input() slotDefault: SlotContent>; + @ContentChild(AlertBodyDirective, {static: false}) + slotDefaultFromContent: AlertBodyDirective | null; + + @Input() slotStructure: SlotContent>; + @ContentChild(AlertStructureDirective, {static: false}) slotStructureFromContent: AlertStructureDirective | undefined; + + /** + * Callback called when the alert visibility changed. + */ + @Output() visibleChange = new EventEmitter(); + + /** + * Callback called when the alert is hidden. + */ + @Output() hidden = new EventEmitter(); + + /** + * Callback called when the alert is shown. + */ + @Output() shown = new EventEmitter(); + + readonly defaultSlots = writable(defaultConfig); + readonly _widget = callWidgetFactory(createAlert, 'alert', this.defaultSlots); + readonly widget = toSlotContextWidget(this._widget); + readonly api = this._widget.api; + readonly state: Signal = toSignal(this._widget.state$, {requireSync: true}); + + constructor() { + this._widget.patch({ + onVisibleChange: (event) => this.visibleChange.emit(event), + onShown: () => this.shown.emit(), + onHidden: () => this.hidden.emit(), + }); + } + + ngAfterContentChecked(): void { + this._widget.patchSlots({ + slotDefault: this.slotDefaultFromContent?.templateRef, + slotStructure: this.slotStructureFromContent?.templateRef, + }); + } + + ngOnChanges(changes: SimpleChanges): void { + patchSimpleChanges(this._widget.patch, changes); + } +} diff --git a/angular/lib/src/lib/modal/modal.component.ts b/angular/lib/src/lib/modal/modal.component.ts new file mode 100644 index 0000000000..efa19f1474 --- /dev/null +++ b/angular/lib/src/lib/modal/modal.component.ts @@ -0,0 +1,297 @@ +import {writable} from '@amadeus-it-group/tansu'; +import type {ModalBeforeCloseEvent, ModalContext as ModalCoreContext, TransitionFn, WidgetProps, WidgetState} from '@agnos-ui/core'; +import {createModal, mergeDirectives, toSlotContextWidget} from '@agnos-ui/core'; +import {NgIf} from '@angular/common'; +import type {AfterContentChecked, OnChanges, Signal, SimpleChanges} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + Directive, + EventEmitter, + Input, + Output, + TemplateRef, + ViewChild, + effect, + inject, +} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import type {AdaptSlotContentProps, AdaptWidgetSlots, SlotContent} from '../slot.directive'; +import {ComponentTemplate, SlotDirective, callWidgetFactory} from '../slot.directive'; +import {SlotDefaultDirective} from '../slotDefault.directive'; +import {UseDirective} from '../transition/use.directive'; +import {patchSimpleChanges} from '../utils'; + +export type ModalWidget = AdaptWidgetSlots>; +export type ModalProps = WidgetProps; +export type ModalState = WidgetState; +export type ModalContext = AdaptSlotContentProps; + +/** + * Directive to provide the slot structure for the modal widget. + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auModalStructure]', standalone: true}) +export class ModalStructureDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: ModalStructureDirective, context: unknown): context is ModalContext { + return true; + } +} + +/** + * Directive to provide the slot header for the modal widget. + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auModalHeader]', standalone: true}) +export class ModalHeaderDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: ModalHeaderDirective, context: unknown): context is ModalContext { + return true; + } +} + +/** + * Directive to provide the slot title for the modal widget. + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auModalTitle]', standalone: true}) +export class ModalTitleDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: ModalTitleDirective, context: unknown): context is ModalContext { + return true; + } +} + +/** + * Directive to provide the default slot for the modal widget. + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auModalBody]', standalone: true}) +export class ModalBodyDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: ModalBodyDirective, context: unknown): context is ModalContext { + return true; + } +} + +/** + * Directive to provide the slot footer for the modal widget. + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auModalFooter]', standalone: true}) +export class ModalFooterDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: ModalFooterDirective, context: unknown): context is ModalContext { + return true; + } +} + +/** + * Component containing the default slots for the modal. + */ +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIf, SlotDirective, ModalHeaderDirective, ModalStructureDirective], + template: ` + + + + + + + + + + `, +}) +export class ModalDefaultSlotsComponent { + @ViewChild('header', {static: true}) header: TemplateRef; + @ViewChild('structure', {static: true}) structure: TemplateRef; +} + +/** + * Default slot for modal header. + */ +export const modalDefaultSlotHeader = new ComponentTemplate(ModalDefaultSlotsComponent, 'header'); + +/** + * Default slot for modal structure. + */ +export const modalDefaultSlotStructure = new ComponentTemplate(ModalDefaultSlotsComponent, 'structure'); + +const defaultConfig: Partial = { + slotHeader: modalDefaultSlotHeader, + slotStructure: modalDefaultSlotStructure, +}; + +/** + * Modal component. + */ +@Component({ + selector: 'au-modal', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [UseDirective, NgIf, SlotDirective, SlotDefaultDirective], + template: ` + + + + `, +}) +export class ModalComponent implements OnChanges, AfterContentChecked { + /** + * Whether the modal and its backdrop (if present) should be animated when shown or hidden. + */ + @Input() animation: boolean | undefined; + + /** + * The transition to use for the backdrop behind the modal (if present). + */ + @Input() backdropTransition: TransitionFn | undefined; + + /** + * The transition to use for the modal. + */ + @Input() modalTransition: TransitionFn | undefined; + + /** + * Whether the modal should be visible when the transition is completed. + */ + @Input() visible: boolean | undefined; + + /** + * Whether a backdrop should be created behind the modal. + */ + @Input() backdrop: boolean | undefined; + + /** + * Whether the modal should be closed when clicking on the viewport outside the modal. + */ + @Input() closeOnOutsideClick: boolean | undefined; + + /** + * Which element should contain the modal and backdrop DOM elements. + * If it is not null, the modal and backdrop DOM elements are moved to the specified container. + * Otherwise, they stay where the widget is located. + */ + @Input() container: HTMLElement | null | undefined; + + /** + * Value of the aria-label attribute to put on the close button. + */ + @Input() ariaCloseButtonLabel: string | undefined; + + /** + * Classes to add on the backdrop DOM element. + */ + @Input() backdropClass: string | undefined; + + /** + * Whether to display the close button. + */ + @Input() closeButton: boolean | undefined; + + /** + * Classes to add on the modal DOM element. + */ + @Input() modalClass: string | undefined; + + @Input() slotStructure: SlotContent; + @ContentChild(ModalStructureDirective, {static: false}) + slotStructureFromContent: ModalStructureDirective | null; + + @Input() slotHeader: SlotContent; + @ContentChild(ModalHeaderDirective, {static: false}) + slotHeaderFromContent: ModalHeaderDirective | null; + + @Input() slotTitle: SlotContent; + @ContentChild(ModalTitleDirective, {static: false}) + slotTitleFromContent: ModalTitleDirective | null; + + @Input() slotDefault: SlotContent; + @ContentChild(ModalBodyDirective, {static: false}) + slotDefaultFromContent: ModalBodyDirective | null; + + @Input() slotFooter: SlotContent; + @ContentChild(ModalFooterDirective, {static: false}) + slotFooterFromContent: ModalFooterDirective | null; + + /** + * Event to be triggered when the visible property changes. + */ + @Output() visibleChange = new EventEmitter(); + + /** + * Event to be triggered when the modal is about to be closed (i.e. the close method was called). + */ + @Output() beforeClose = new EventEmitter(); + + /** + * Event to be triggered when the transition is completed and the modal is not visible. + */ + @Output() hidden = new EventEmitter(); + + /** + * Event to be triggered when the transition is completed and the modal is visible. + */ + @Output() shown = new EventEmitter(); + + readonly defaultSlots = writable(defaultConfig); + readonly _widget = callWidgetFactory(createModal, 'modal', this.defaultSlots); + readonly widget = toSlotContextWidget(this._widget); + readonly api = this._widget.api; + readonly modalDirective = mergeDirectives(this._widget.directives.modalPortalDirective, this._widget.directives.modalDirective); + readonly backdropDirective = mergeDirectives(this._widget.directives.backdropPortalDirective, this._widget.directives.backdropDirective); + + readonly state: Signal = toSignal(this._widget.state$, {requireSync: true}); + + constructor() { + this._widget.patch({ + onShown: () => this.shown.emit(), + onHidden: () => this.hidden.emit(), + onBeforeClose: (event) => this.beforeClose.emit(event), + onVisibleChange: (event) => this.visibleChange.emit(event), + }); + effect(() => { + // TODO: workaround to be removed when https://github.com/angular/angular/issues/50320 is fixed + this.state(); + }); + } + + ngAfterContentChecked(): void { + this._widget.patchSlots({ + slotDefault: this.slotDefaultFromContent?.templateRef, + slotFooter: this.slotFooterFromContent?.templateRef, + slotHeader: this.slotHeaderFromContent?.templateRef, + slotStructure: this.slotStructureFromContent?.templateRef, + slotTitle: this.slotTitleFromContent?.templateRef, + }); + } + + ngOnChanges(changes: SimpleChanges): void { + patchSimpleChanges(this._widget.patch, changes); + } +} diff --git a/angular/lib/src/lib/modal/modal.service.ts b/angular/lib/src/lib/modal/modal.service.ts new file mode 100644 index 0000000000..223d1e2b75 --- /dev/null +++ b/angular/lib/src/lib/modal/modal.service.ts @@ -0,0 +1,42 @@ +import {ApplicationRef, createComponent, EnvironmentInjector, EventEmitter, inject, Injectable, Injector} from '@angular/core'; +import type {ModalProps} from './modal.component'; +import {ModalComponent} from './modal.component'; + +export interface ModalServiceOpenOptions { + injector?: Injector; +} + +@Injectable({providedIn: 'root'}) +export class ModalService { + private _injector = inject(Injector); + private _applicationRef = inject(ApplicationRef); + + async open(options: Partial, {injector = this._injector}: ModalServiceOpenOptions = {}): Promise { + const component = createComponent(ModalComponent, { + environmentInjector: injector.get(EnvironmentInjector), + elementInjector: injector, + }); + const subscriptions = []; + try { + for (const prop of Object.keys(options) as (string & keyof ModalProps)[]) { + const value = options[prop]; + if (prop.startsWith('on')) { + const eventName = `${prop[2].toLowerCase()}${prop.substring(3)}`; + const eventEmitter = (component.instance as any)[eventName]; + if (eventEmitter instanceof EventEmitter) { + subscriptions.push(eventEmitter.subscribe(value)); + } + } else { + component.setInput(prop, value); + } + } + this._applicationRef.attachView(component.hostView); + return await component.instance.api.open(); + } finally { + component.destroy(); + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + } + } +} diff --git a/angular/lib/src/lib/pagination/pagination.component.ts b/angular/lib/src/lib/pagination/pagination.component.ts new file mode 100644 index 0000000000..74e52a6877 --- /dev/null +++ b/angular/lib/src/lib/pagination/pagination.component.ts @@ -0,0 +1,409 @@ +import type {WidgetProps, PaginationContext as PaginationCoreContext, PaginationNumberContext as PaginationNumberCoreContext} from '@agnos-ui/core'; +import {createPagination, toSlotContextWidget} from '@agnos-ui/core'; +import {AsyncPipe, NgForOf, NgIf} from '@angular/common'; +import type {AfterContentChecked, OnChanges, SimpleChanges} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + Directive, + EventEmitter, + Input, + Output, + TemplateRef, + ViewChild, + ViewEncapsulation, + inject, +} from '@angular/core'; +import type {AdaptWidgetSlots, AdaptSlotContentProps, SlotContent} from '../slot.directive'; +import {ComponentTemplate, SlotDirective, callWidgetFactory} from '../slot.directive'; +import {patchSimpleChanges} from '../utils'; + +/** + * A type for the context of the pagination slot + */ +export type PaginationContext = AdaptSlotContentProps; + +/** + * A type for the context of the pagination number Slot + */ +export type PaginationNumberContext = AdaptSlotContentProps; +/** + * A type for the widget of the pagination + */ +export type PaginationWidget = AdaptWidgetSlots>; +/** + * A type for the props of the pagination + */ +export type PaginationProps = WidgetProps; + +/** + * A directive to use to give the 'ellipsis' link template to the pagination component + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auPaginationEllipsis]', standalone: true}) +export class PaginationEllipsisDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: PaginationEllipsisDirective, context: unknown): context is PaginationContext { + return true; + } +} + +/** + * A directive to use to give the 'first' link template to the pagination component + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auPaginationFirst]', standalone: true}) +export class PaginationFirstDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: PaginationFirstDirective, context: unknown): context is PaginationContext { + return true; + } +} + +/** + * A directive to use to give the 'last' link template to the pagination component + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auPaginationLast]', standalone: true}) +export class PaginationLastDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: PaginationLastDirective, context: unknown): context is PaginationContext { + return true; + } +} + +/** + * A directive to use to give the 'next' link template to the pagination component + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auPaginationNext]', standalone: true}) +export class PaginationNextDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: PaginationNextDirective, context: unknown): context is PaginationContext { + return true; + } +} + +/** + * A directive to use to give the page 'number' template to the pagination component + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auPaginationNumber]', standalone: true}) +export class PaginationNumberDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: PaginationNumberDirective, context: unknown): context is PaginationNumberContext { + return true; + } +} + +/** + * A directive to use to give the 'previous' link template to the pagination component + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auPaginationPrevious]', standalone: true}) +export class PaginationPreviousDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: PaginationPreviousDirective, context: unknown): context is PaginationContext { + return true; + } +} + +/** + * A directive to use to give the 'Pages' template for the Pages slot + */ +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auPaginationPages]', standalone: true}) +export class PaginationPagesDirective { + public templateRef = inject(TemplateRef); + static ngTemplateContextGuard(dir: PaginationPagesDirective, context: unknown): context is PaginationContext { + return true; + } +} + +@Component({ + standalone: true, + imports: [NgForOf, NgIf, SlotDirective, PaginationPagesDirective], + template: ` +
  • + + + + + + {{ state.activeLabel }} + +
  • +
    `, +}) +export class PaginationDefaultSlotsComponent { + @ViewChild('pages', {static: true}) pages: TemplateRef; +} +/** + * The default slot for the pages + */ +export const paginationDefaultSlotPages = new ComponentTemplate(PaginationDefaultSlotsComponent, 'pages'); + +const defaultConfig: Partial = { + slotPages: paginationDefaultSlotPages, +}; + +@Component({ + selector: 'au-pagination', + standalone: true, + imports: [NgIf, AsyncPipe, SlotDirective], + host: {role: 'navigation'}, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + + + `, +}) +export class PaginationComponent implements OnChanges, AfterContentChecked { + /** + * Provide the label for each "Page" page button. + * This is used for accessibility purposes. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @param processPage - The current page number + * @param pageCount - The total number of pages + */ + @Input() ariaPageLabel: ((processPage: number, pageCount: number) => string) | undefined; + + /** + * The label for the "active" page. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue '(current)' + */ + @Input() activeLabel: string | undefined; + + /** + * The label for the "First" page button. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue 'Action link for first page' + */ + @Input() ariaFirstLabel: string | undefined; + + /** + * The label for the "Previous" page button. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue 'Action link for previous page' + */ + @Input() ariaPreviousLabel: string | undefined; + + /** + * The label for the "Next" page button. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue 'Action link for next page' + */ + @Input() ariaNextLabel: string | undefined; + + /** + * The label for the "Last" page button. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue 'Action link for last page' + */ + @Input() ariaLastLabel: string | undefined; + + readonly _widget = callWidgetFactory(createPagination, 'pagination', defaultConfig); + readonly widget = toSlotContextWidget(this._widget); + readonly api = this._widget.api; + + @Input() slotEllipsis: SlotContent; + @ContentChild(PaginationEllipsisDirective, {static: false}) + slotEllipsisFromContent: PaginationEllipsisDirective | undefined; + + @Input() slotFirst: SlotContent; + @ContentChild(PaginationFirstDirective, {static: false}) + slotFirstFromContent: PaginationFirstDirective | undefined; + + @Input() slotPrevious: SlotContent; + @ContentChild(PaginationPreviousDirective, {static: false}) + slotPreviousFromContent: PaginationPreviousDirective | undefined; + + @Input() slotNext: SlotContent; + @ContentChild(PaginationNextDirective, {static: false}) + slotNextFromContent: PaginationNextDirective | undefined; + + @Input() slotLast: SlotContent; + @ContentChild(PaginationLastDirective, {static: false}) + slotLastFromContent: PaginationLastDirective | undefined; + + @Input() slotPages: SlotContent; + @ContentChild(PaginationPagesDirective, {static: false}) + slotPagesFromContent: PaginationPagesDirective | undefined; + + @Input() slotNumberLabel: SlotContent; + @ContentChild(PaginationNumberDirective, {static: false}) + slotNumberLabelFromContent: PaginationNumberDirective | undefined; + + /** + * If `true`, pagination links will be disabled. + */ + @Input() disabled: boolean | undefined; + + /** + * If `true`, the "First" and "Last" page links are shown. + */ + @Input() boundaryLinks: boolean | undefined; + + /** + * If `true`, the "Next" and "Previous" page links are shown. + */ + @Input() directionLinks: boolean | undefined; + + /** + * The number of items in your paginated collection. + * + * Note, that this is not the number of pages. Page numbers are calculated dynamically based on + * `collectionSize` and `pageSize`. Ex. if you have 100 items in your collection and displaying 20 items per page, + * you'll end up with 5 pages. + * Whatever the collectionSize the page number is of minimum 1. + * @defaultValue 0 + */ + @Input() collectionSize: number | undefined; + + /** + * The current page. + * + * Page numbers start with `1`. + * @defaultValue 1 + */ + @Input() page: number | undefined; + + /** + * The number of items per page. + * @defaultValue 10 + */ + @Input() pageSize: number | undefined; + + /** + * The pagination display size. + * + * Bootstrap currently supports small and large sizes. + * @defaultValue null + */ + @Input() size: 'sm' | 'lg' | null | undefined; + + /** + * pagesFactory returns a function computing the array of pages to be displayed + * as number (-1 are treated as ellipsis). + * Use Page slot to customize the pages view and not this + */ + @Input() pagesFactory: ((page: number, pageCount: number) => number[]) | undefined; + + /** + * An event fired when the page is changed. + * + * Event payload is the number of the newly selected page. + * + * Page numbers start with `1`. + */ + @Output() pageChange = new EventEmitter(true); + + /** + * An input to add a custom class to the UL + */ + @Input() className: string | undefined; + + constructor() { + this._widget.patch({ + onPageChange: (page: number) => this.pageChange.emit(page), + }); + } + + ngOnChanges(changes: SimpleChanges): void { + patchSimpleChanges(this._widget.patch, changes); + } + + ngAfterContentChecked(): void { + this._widget.patchSlots({ + slotEllipsis: this.slotEllipsisFromContent?.templateRef, + slotFirst: this.slotFirstFromContent?.templateRef, + slotLast: this.slotLastFromContent?.templateRef, + slotNext: this.slotNextFromContent?.templateRef, + slotNumberLabel: this.slotNumberLabelFromContent?.templateRef, + slotPages: this.slotPagesFromContent?.templateRef, + slotPrevious: this.slotPreviousFromContent?.templateRef, + }); + } +} diff --git a/angular/lib/src/lib/rating/rating.component.ts b/angular/lib/src/lib/rating/rating.component.ts new file mode 100644 index 0000000000..a96b7f72b1 --- /dev/null +++ b/angular/lib/src/lib/rating/rating.component.ts @@ -0,0 +1,207 @@ +import type {StarContext, WidgetProps, WidgetState} from '@agnos-ui/core'; +import {createRating} from '@agnos-ui/core'; +import {NgForOf} from '@angular/common'; +import type {AfterContentChecked, OnChanges, Signal, SimpleChanges} from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ContentChild, + Directive, + EventEmitter, + HostBinding, + Input, + Output, + TemplateRef, + ViewEncapsulation, + forwardRef, + inject, +} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import type {ControlValueAccessor} from '@angular/forms'; +import {NG_VALUE_ACCESSOR} from '@angular/forms'; +import type {AdaptSlotContentProps, AdaptWidgetSlots, SlotContent} from '../slot.directive'; +import {SlotDirective, callWidgetFactory} from '../slot.directive'; +import {patchSimpleChanges} from '../utils'; + +export type RatingWidget = AdaptWidgetSlots>; +export type RatingProps = WidgetProps; +export type RatingState = WidgetState; + +// eslint-disable-next-line @angular-eslint/directive-selector +@Directive({selector: 'ng-template[auRatingStar]', standalone: true}) +export class RatingStarDirective { + public templateRef = inject(TemplateRef>); + static ngTemplateContextGuard(_dir: RatingStarDirective, context: unknown): context is StarContext { + return true; + } +} + +@Component({ + selector: 'au-rating', + standalone: true, + imports: [NgForOf, SlotDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + host: { + class: 'd-inline-flex au-rating', + '[tabindex]': 'state$().tabindex', + role: 'slider', + 'aria-valuemin': '0', + '[attr.aria-valuemax]': 'state$().maxRating', + '[attr.aria-valuenow]': 'state$().visibleRating', + '[attr.aria-valuetext]': 'state$().ariaValueText', + '[attr.aria-disabled]': 'state$().disabled ? true : null', + '[attr.aria-readonly]': 'state$().readonly ? true : null', + '[attr.aria-label]': 'state$().ariaLabel || null', + '(blur)': 'onTouched()', + '(keydown)': '_widget.actions.handleKey($event)', + '(mouseleave)': '_widget.actions.leave()', + '[class]': 'state$().className', + }, + template: ` + + ({{ index < state$().visibleRating ? '*' : ' ' }}) + + + + + `, + providers: [{provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RatingComponent), multi: true}], +}) +export class RatingComponent implements ControlValueAccessor, OnChanges, AfterContentChecked { + readonly _widget = callWidgetFactory(createRating, 'rating'); + readonly api = this._widget.api; + + state$: Signal = toSignal(this._widget.state$, {requireSync: true}); + + onChange = (_: any) => {}; + onTouched = () => {}; + + // TODO angular is failing when adding this host binding in decorator part + @HostBinding('[attr.aria-labelledby]') get hostAriaLabelledBy() { + return this.state$().ariaLabelledBy || null; + } + + /** + * Return the value for the 'aria-value' attribute. + */ + @Input() ariaValueTextFn: ((rating: number, maxRating: number) => string) | undefined; + + /** + * If `true`, the rating is disabled. + */ + @Input() disabled: boolean | undefined; + + /** + * The maximum rating that can be given. + */ + @Input() maxRating: number | undefined; + + /** + * The current rating. Could be a decimal value like `3.75`. + */ + @Input() rating: number | undefined; + + /** + * If `true`, the rating can't be changed. + */ + @Input() readonly: boolean | undefined; + + /** + * Define if the rating can be reset. + * + * If set to true, the user can 'unset' the rating value by cliking on the current rating value. + */ + @Input() resettable: boolean | undefined; + + @Input() slotStar: SlotContent>; + @ContentChild(RatingStarDirective, {static: false}) slotStarFromContent: RatingStarDirective | undefined; + + /** + * Allows setting a custom rating tabindex. + * If the component is disabled, `tabindex` will still be set to `-1`. + */ + @Input() tabindex: number | undefined; + + /** + * Classname to be applied on the rating container + */ + @Input() className: string | undefined; + + /** + * The aria label + */ + @Input() ariaLabel: string | undefined; + + /** + * The aria labelled by + */ + @Input() ariaLabelledBy: string | undefined; + + /** + * An event emitted when the user is hovering over a given rating. + * + * Event payload is equal to the rating being hovered over. + */ + @Output() hover = new EventEmitter(); + + /** + * An event emitted when the user stops hovering over a given rating. + * + * Event payload is equal to the rating of the last item being hovered over. + */ + @Output() leave = new EventEmitter(); + + /** + * An event emitted when the rating is changed. + * + * Event payload is equal to the newly selected rating. + */ + @Output() ratingChange = new EventEmitter(); + + constructor() { + this._widget.patch({ + onHover: (event) => this.hover.emit(event), + onLeave: (event) => this.leave.emit(event), + onRatingChange: (rating: number) => { + this.ratingChange.emit(rating); + this.onChange(rating); + }, + }); + } + + writeValue(value: any): void { + this._widget.patch({rating: value}); + } + + registerOnChange(fn: (value: any) => any): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => any): void { + this.onTouched = fn; + } + + setDisabledState(disabled: boolean): void { + this._widget.patch({disabled}); + } + + ngOnChanges(changes: SimpleChanges): void { + patchSimpleChanges(this._widget.patch, changes); + } + + ngAfterContentChecked(): void { + this._widget.patchSlots({ + slotStar: this.slotStarFromContent?.templateRef, + }); + } + + trackByIndex(index: number) { + return index; + } +} diff --git a/angular/lib/src/lib/select/select.component.html b/angular/lib/src/lib/select/select.component.html new file mode 100644 index 0000000000..6b99469ab6 --- /dev/null +++ b/angular/lib/src/lib/select/select.component.html @@ -0,0 +1,54 @@ + +
    + +
    +
    +
    {{ item }}
    + x +
    +
    +
    + +
    + +
    + diff --git a/angular/lib/src/lib/select/select.component.ts b/angular/lib/src/lib/select/select.component.ts new file mode 100644 index 0000000000..fa8a213f9f --- /dev/null +++ b/angular/lib/src/lib/select/select.component.ts @@ -0,0 +1,95 @@ +import type {WidgetProps, WidgetState, ItemCtx} from '@agnos-ui/core'; +import {createSelect} from '@agnos-ui/core'; +import {CommonModule} from '@angular/common'; +import type {OnChanges, Signal, SimpleChanges} from '@angular/core'; +import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import type {AdaptWidgetSlots} from '../slot.directive'; +import {callWidgetFactory} from '../slot.directive'; +import {UseDirective} from '../transition/use.directive'; +import {patchSimpleChanges} from '../utils'; + +export type SelectWidget = AdaptWidgetSlots>>; +export type SelectProps = WidgetProps>; + +@Component({ + standalone: true, + imports: [UseDirective, CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'au-select', + templateUrl: './select.component.html', + styles: [], + host: { + '[class]': '"au-select dropdown input-group input-group-sm mb-3 " + state$().className', + style: 'display: block', + }, +}) +export class SelectComponent implements OnChanges { + /** + * List of available items for the dropdown + */ + @Input() items: Item[] | undefined; + + /** + * true if the select is open + */ + @Input() opened: boolean | undefined; + + /** + * Filtered text to be display in the filter input + */ + @Input() filterText: string | undefined; + + @Input() className: string | undefined; + + /** + * Callback called when the text filter change + */ + @Output() filterTextChange = new EventEmitter(); + + /** + * true if the select is disabled + */ + @Input() disabled: boolean | undefined; + + /** + * Custom function to filter an item. + * By default, item is considered as a string, and the function returns true if the text is found + */ + @Input() matchFn: ((item: Item, text: string) => boolean) | undefined; + + /** + * Custom function to get the id of an item + * By default, the item is returned + */ + @Input() itemId: ((item: Item) => string) | undefined; + + /** + * List of selected items + */ + @Input() selected: Item[] | undefined; + + /** + * true if a loading process is being done + */ + @Input() loading: boolean | undefined; + + readonly _widget = callWidgetFactory>(createSelect, 'select'); + readonly api = this._widget.api; + + state$: Signal>> = toSignal(this._widget.state$, {requireSync: true}); + + constructor() { + this._widget.patch({ + onFilterTextChange: (event) => this.filterTextChange.emit(event), + }); + } + + ngOnChanges(changes: SimpleChanges) { + patchSimpleChanges(this._widget.patch, changes); + } + + itemCtxTrackBy(_: number, itemCtx: ItemCtx) { + return itemCtx.id; + } +} diff --git a/angular/lib/src/lib/slot.directive.spec.ts b/angular/lib/src/lib/slot.directive.spec.ts new file mode 100644 index 0000000000..20b9ace2a5 --- /dev/null +++ b/angular/lib/src/lib/slot.directive.spec.ts @@ -0,0 +1,204 @@ +import {writable} from '@amadeus-it-group/tansu'; +import type {TemplateRef} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Injectable, Input, ViewChild, inject} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import type {SlotContent} from './slot.directive'; +import {ComponentTemplate, SlotDirective, injectWidgetsConfig, provideWidgetsConfig} from './slot.directive'; +import {toSignal} from '@angular/core/rxjs-interop'; + +describe('slot directive', () => { + @Component({ + selector: 'au-test-slot-directive', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SlotDirective], + template: '', + }) + class TestComponent { + @Input() mySlot: SlotContent<{myProp: string}>; + @Input() mySlotProps = {myProp: 'world'}; + } + + it('undefined', () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + fixture.destroy(); + expect(fixture.nativeElement.textContent).toBe(''); + }); + + it('null', () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.componentRef.setInput('mySlot', null); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + fixture.destroy(); + expect(fixture.nativeElement.textContent).toBe(''); + }); + + it('string', () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.componentRef.setInput('mySlot', 'hello'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('hello'); + fixture.componentRef.setInput('mySlotProps', {myProp: 'to you'}); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('hello'); + fixture.componentRef.setInput('mySlot', 'goodbye!'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('goodbye!'); + fixture.destroy(); + expect(fixture.nativeElement.textContent).toBe(''); + }); + + it('function', () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.componentRef.setInput('mySlot', (props: {myProp: string}) => `hello ${props.myProp}!`); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('hello world!'); + fixture.componentRef.setInput('mySlotProps', {myProp: 'to you'}); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('hello to you!'); + fixture.componentRef.setInput('mySlot', (props: {myProp: string}) => `goodbye ${props.myProp}!`); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('goodbye to you!'); + fixture.destroy(); + expect(fixture.nativeElement.textContent).toBe(''); + }); + + it('component', () => { + @Component({ + selector: 'au-test-slot-directive-component-hello', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: 'Hello {{myProp}}!', + }) + class HelloComponent { + @Input() myProp: string; + } + + @Component({ + selector: 'au-test-slot-directive-component-goodbye', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: 'Goodbye {{myProp}}!', + }) + class GoodbyeComponent { + @Input() myProp: string; + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.componentRef.setInput('mySlot', HelloComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Hello world!'); + fixture.componentRef.setInput('mySlotProps', {myProp: 'to you'}); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Hello to you!'); + fixture.componentRef.setInput('mySlot', GoodbyeComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Goodbye to you!'); + fixture.destroy(); + expect(fixture.nativeElement.textContent).toBe(''); + }); + + it('template', () => { + @Component({ + selector: 'au-test-slot-directive-template', + standalone: true, + imports: [SlotDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + Hello {{ myProp }}! + Goodbye {{ myProp }}! + + `, + }) + class TemplateTestComponent { + @ViewChild('hello') hello: TemplateRef<{myProp: string}>; + @ViewChild('goodbye') goodbye: TemplateRef<{myProp: string}>; + @Input() mySlot: SlotContent<{myProp: string}>; + @Input() mySlotProps = {myProp: 'world'}; + } + + const fixture = TestBed.createComponent(TemplateTestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe(''); + fixture.componentRef.setInput('mySlot', fixture.componentInstance.hello); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Hello world!'); + fixture.componentRef.setInput('mySlotProps', {myProp: 'to you'}); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Hello to you!'); + fixture.componentRef.setInput('mySlot', fixture.componentInstance.goodbye); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Goodbye to you!'); + fixture.destroy(); + expect(fixture.nativeElement.textContent).toBe(''); + }); + + it('component template', () => { + @Component({ + selector: 'au-test-slot-directive-component-template', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + Hello {{ myProp }}! + Goodbye {{ myProp }}! + `, + }) + class HelloAndGoodbyeComponent { + @ViewChild('hello', {static: true}) hello: TemplateRef<{myProp: string}>; + @ViewChild('goodbye', {static: true}) goodbye: TemplateRef<{myProp: string}>; + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.componentRef.setInput('mySlot', new ComponentTemplate(HelloAndGoodbyeComponent, 'hello')); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Hello world!'); + fixture.componentRef.setInput('mySlotProps', {myProp: 'to you'}); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Hello to you!'); + fixture.componentRef.setInput('mySlot', new ComponentTemplate(HelloAndGoodbyeComponent, 'goodbye')); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('Goodbye to you!'); + fixture.destroy(); + expect(fixture.nativeElement.textContent).toBe(''); + }); +}); + +describe('widgets config', () => { + it('should work to use inject in adaptParentConfig', () => { + @Injectable({ + providedIn: 'root', + }) + class MySettingsClass { + maxRating$ = writable(5); + } + + @Component({ + selector: 'au-test-widget-config', + template: `maxRating = {{ myConfig().rating.maxRating }}`, + providers: [ + provideWidgetsConfig((parentConfig) => { + if (!parentConfig.rating) { + parentConfig.rating = {}; + } + parentConfig.rating.maxRating = inject(MySettingsClass).maxRating$(); + return parentConfig; + }), + ], + }) + class MyTestComponent { + myConfig = toSignal(injectWidgetsConfig(), {requireSync: true}); + } + + const component = TestBed.createComponent(MyTestComponent); + component.detectChanges(); + expect(component.nativeElement.textContent).toEqual('maxRating = 5'); + const settings = TestBed.inject(MySettingsClass); + settings.maxRating$.set(10); + component.detectChanges(); + expect(component.nativeElement.textContent).toEqual('maxRating = 10'); + component.destroy(); + }); +}); diff --git a/angular/lib/src/lib/slot.directive.ts b/angular/lib/src/lib/slot.directive.ts new file mode 100644 index 0000000000..23c0b0b936 --- /dev/null +++ b/angular/lib/src/lib/slot.directive.ts @@ -0,0 +1,399 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import {computed, readable, writable} from '@amadeus-it-group/tansu'; +import type { + SlotContent as CoreSlotContent, + WidgetsConfig as CoreWidgetsConfig, + Partial2Levels, + Widget, + WidgetFactory, + WidgetProps, + WidgetSlotContext, + WidgetState, + WidgetsConfigStore, +} from '@agnos-ui/core'; +import {createWidgetsConfig} from '@agnos-ui/core'; +import {DOCUMENT} from '@angular/common'; +import type {ComponentRef, EmbeddedViewRef, FactoryProvider, OnChanges, OnDestroy, SimpleChanges, Type} from '@angular/core'; +import { + Directive, + EnvironmentInjector, + InjectionToken, + Injector, + Input, + Optional, + SkipSelf, + TemplateRef, + ViewContainerRef, + createComponent, + inject, + reflectComponentType, + runInInjectionContext, +} from '@angular/core'; + +export class ComponentTemplate}> { + constructor(public readonly component: Type, public readonly templateProp: K) {} +} + +export type SlotContent = + | CoreSlotContent + | TemplateRef + | Type + | ComponentTemplate; + +export type AdaptSlotContentProps> = Props extends WidgetSlotContext + ? WidgetSlotContext> & AdaptPropsSlots>> + : AdaptPropsSlots; + +export type AdaptPropsSlots = Omit & { + [K in keyof Props & `slot${string}`]: Props[K] extends CoreSlotContent ? SlotContent> : Props[K]; +}; + +export type AdaptWidgetSlots = Widget< + AdaptPropsSlots>, + AdaptPropsSlots>, + W['api'], + W['actions'], + W['directives'] +>; + +export type WidgetsConfig = { + [WidgetName in keyof CoreWidgetsConfig]: AdaptPropsSlots; +}; + +/** + * Dependency Injection token which can be used to provide or inject the widgets default configuration store. + */ +export const widgetsConfigInjectionToken = new InjectionToken>('widgetsConfig'); + +/** + * Creates a provider of widgets default configuration that inherits from any widgets default configuration already defined at an upper level + * in the Angular dependency injection system. It contains its own set of widgets configuration properties that override the same properties form + * the parent configuration. + * + * @remarks + * The configuration is computed from the parent configuration in two steps: + * - first step: the parent configuration is transformed by the adaptParentConfig function (if specified). + * If adaptParentConfig is not specified, this step is skipped. + * - second step: the configuration from step 1 is merged (2-levels deep) with the own$ store. The own$ store initially contains + * an empty object (i.e. no property from the parent is overridden). It can be changed by calling set on the store returned by + * {@link injectWidgetsConfig}. + * + * @param adaptParentConfig - optional function that receives a 2-levels copy of the widgets default configuration + * defined at an upper level in the Angular dependency injection system (or an empty object if there is none) and returns the widgets + * default configuration to be used. + * It is called only if the configuration is needed, and was not yet computed for the current value of the parent configuration. + * It is called in a tansu reactive context, so it can use any tansu store and will be called again if those stores change. + * It is also called in an Angular injection context, so it can call the Angular inject function to get and use dependencies from the + * Angular dependency injection system. + + * @returns DI provider to be included a list of `providers` (for example at a component level or + * any other level of the Angular dependency injection system) + * + * @example + * ```typescript + * @Component({ + * // ... + * providers: [ + * provideWidgetsConfig((parentConfig) => { + * // first step configuration: transforms the parent configuration + * parentConfig.rating = parentConfig.rating ?? {}; + * parentConfig.rating.className = `${parentConfig.rating.className ?? ''} my-rating-extra-class` + * return parentConfig; + * }) + * ] + * }) + * class MyComponent { + * widgetsConfig = injectWidgetsConfig(); + * constructor() { + * this.widgetsConfig.set({ + * // second step configuration: overrides the parent configuration + * rating: { + * slotStar: MyCustomSlotStar + * } + * }); + * } + * // ... + * } + * ``` + */ +export const provideWidgetsConfig = ( + adaptParentConfig?: (config: Partial2Levels) => Partial2Levels +): FactoryProvider => ({ + provide: widgetsConfigInjectionToken, + useFactory: (parent: WidgetsConfigStore | null) => { + if (adaptParentConfig) { + const injector = inject(Injector); + const originalAdaptParentConfig = adaptParentConfig; + adaptParentConfig = (value) => runInInjectionContext(injector, () => originalAdaptParentConfig(value)); + } + return createWidgetsConfig(parent ?? undefined, adaptParentConfig); + }, + deps: [[new SkipSelf(), new Optional(), widgetsConfigInjectionToken]], +}); + +/** + * Returns the widgets default configuration store that was provided in the current injection context. + * Throws if the no widgets default configuration store was provided. + * + * @remarks + * This function must be called from an injection context, such as a constructor, a factory function, a field initializer or + * a function used with {@link https://angular.io/api/core/runInInjectionContext | runInInjectionContext}. + * + * @returns the widgets default configuration store. + */ +export const injectWidgetsConfig = () => inject(widgetsConfigInjectionToken); + +const createPatchSlots = (set: (object: Partial) => void) => { + let lastValue: Partial = {}; + return (object: T) => { + const newValue: Partial = {}; + let hasChange = false; + for (const key of Object.keys(object) as (string & keyof T)[]) { + const objectKey = (object as any)[key]; + if (objectKey != null) { + // only use defined slots + newValue[key] = objectKey; + } + if (objectKey != lastValue[key]) { + hasChange = true; + } + } + if (hasChange) { + lastValue = newValue; + set(newValue); + } + }; +}; + +export type WithPatchSlots = AdaptWidgetSlots & { + patchSlots(slots: { + [K in keyof WidgetProps & `slot${string}`]: WidgetProps[K] extends CoreSlotContent + ? TemplateRef> | undefined + : never; + }): void; +}; + +export const callWidgetFactory = ( + factory: WidgetFactory, + widgetName: keyof WidgetsConfig | null, + defaultConfig: Partial>> | ReadableSignal>>> = {} +): WithPatchSlots => { + const defaultConfigStore = typeof defaultConfig !== 'function' ? readable(defaultConfig) : defaultConfig; + const slots$ = writable({}); + const widgetsConfig = widgetName ? inject(widgetsConfigInjectionToken, {optional: true}) : undefined; + return { + ...(factory( + computed(() => ({...(defaultConfigStore() as any), ...(widgetName ? widgetsConfig?.()[widgetName] : undefined), ...slots$()})) + ) as any), + patchSlots: createPatchSlots(slots$.set), + }; +}; + +abstract class SlotHandler, Slot extends SlotContent = SlotContent> { + constructor(public viewContainerRef: ViewContainerRef, public document: Document) {} + slotChange(slot: Slot, props: Props) {} + propsChange(slot: Slot, props: Props) {} + destroy() {} +} + +class StringSlotHandler> extends SlotHandler { + #nodeRef: Text | undefined; + #previousText = ''; + + override slotChange(slot: string): void { + if (slot === this.#previousText) { + return; + } + this.#previousText = slot; + if (this.#nodeRef) { + this.#nodeRef.textContent = slot; + } else { + const viewContainerElement: Comment | undefined = this.viewContainerRef.element.nativeElement; + if (this.document && viewContainerElement?.parentNode) { + this.#nodeRef = viewContainerElement.parentNode.insertBefore(this.document.createTextNode(slot), viewContainerElement); + } + } + } + + override destroy(): void { + this.#nodeRef?.parentNode?.removeChild(this.#nodeRef); + this.#nodeRef = undefined; + } +} + +class FunctionSlotHandler> extends SlotHandler string> { + #stringSlotHandler = new StringSlotHandler(this.viewContainerRef, this.document); + + override slotChange(slot: (props: Props) => string, props: Props): void { + this.#stringSlotHandler.slotChange(slot(props)); + } + + override propsChange(slot: (props: Props) => string, props: Props): void { + this.#stringSlotHandler.slotChange(slot(props)); + } + + override destroy(): void { + this.#stringSlotHandler.destroy(); + } +} + +class ComponentSlotHandler> extends SlotHandler> { + #componentRef: ComponentRef | undefined; + #properties: string[]; + + override slotChange(slot: Type, props: Props): void { + if (this.#componentRef) { + this.destroy(); + } + this.#componentRef = this.viewContainerRef.createComponent(slot); + this.#applyProperties(props); + } + + #applyProperties(props: Props, oldProperties?: Set) { + const properties = Object.keys(props); + this.#properties = properties; + const componentRef = this.#componentRef!; + for (const property of properties) { + componentRef.setInput(property, props[property]); + oldProperties?.delete(property); + } + } + + override propsChange(slot: Type, props: Props): void { + const oldProperties = new Set(this.#properties); + this.#applyProperties(props, oldProperties); + const componentRef = this.#componentRef!; + for (const property of oldProperties) { + componentRef.setInput(property, undefined); + } + } + + override destroy(): void { + this.viewContainerRef.clear(); + this.#componentRef = undefined; + } +} + +class TemplateRefSlotHandler> extends SlotHandler> { + #viewRef: EmbeddedViewRef | undefined; + #props: Props; + + override slotChange(slot: TemplateRef, props: Props): void { + if (this.#viewRef) { + this.destroy(); + } + props = {...props}; + this.#props = props; + this.#viewRef = this.viewContainerRef.createEmbeddedView(slot, props); + } + + override propsChange(slot: TemplateRef, props: Props): void { + if (this.#viewRef) { + const templateProps = this.#props; + const oldProperties = new Set(Object.keys(templateProps)); + for (const property of Object.keys(props) as (keyof Props)[]) { + templateProps[property] = props[property]; + oldProperties.delete(property); + } + for (const oldProperty of oldProperties) { + delete templateProps[oldProperty]; + } + this.#viewRef.markForCheck(); + } + } + + override destroy(): void { + this.viewContainerRef.clear(); + } +} + +class ComponentTemplateSlotHandler< + Props extends Record, + K extends string, + T extends {[key in K]: TemplateRef} +> extends SlotHandler> { + #componentRef: ComponentRef | undefined; + #templateSlotHandler = new TemplateRefSlotHandler(this.viewContainerRef, this.document); + #templateRef: TemplateRef | undefined; + + override slotChange(slot: ComponentTemplate, props: Props): void { + if (this.#componentRef) { + this.destroy(); + } + this.#componentRef = createComponent(slot.component, { + elementInjector: this.viewContainerRef.injector, + environmentInjector: this.viewContainerRef.injector.get(EnvironmentInjector), + }); + this.#templateRef = this.#componentRef.instance[slot.templateProp]; + this.#templateSlotHandler.slotChange(this.#templateRef, props); + } + + override propsChange(slot: ComponentTemplate, props: Props): void { + this.#templateSlotHandler.propsChange(this.#templateRef!, props); + } + + override destroy(): void { + this.#templateSlotHandler.destroy(); + this.#componentRef?.destroy(); + this.#componentRef = undefined; + } +} + +const getSlotType = (value: any): undefined | {new (viewContainerRef: ViewContainerRef, document: Document): SlotHandler} => { + if (!value) return undefined; + const type = typeof value; + switch (type) { + case 'string': + return StringSlotHandler; + case 'function': + if (reflectComponentType(value)) { + return ComponentSlotHandler; + } + return FunctionSlotHandler; + case 'object': + if (value instanceof TemplateRef) { + return TemplateRefSlotHandler; + } + if (value instanceof ComponentTemplate) { + return ComponentTemplateSlotHandler; + } + break; + } + return undefined; +}; + +@Directive({ + selector: '[auSlot]', + standalone: true, +}) +export class SlotDirective> implements OnChanges, OnDestroy { + @Input('auSlot') slot: SlotContent; + @Input('auSlotProps') props: Props; + + private _viewContainerRef = inject(ViewContainerRef); + private _document = inject(DOCUMENT); + private _slotType: ReturnType; + private _slotHandler: SlotHandler | undefined; + + ngOnChanges(changes: SimpleChanges): void { + const slotChange = changes['slot']; + const propsChange = changes['props']; + const slot = this.slot; + if (slotChange) { + const newSlotType = getSlotType(slot); + if (newSlotType !== this._slotType) { + this._slotHandler?.destroy(); + this._slotHandler = newSlotType ? new newSlotType(this._viewContainerRef, this._document) : undefined; + this._slotType = newSlotType; + } + this._slotHandler?.slotChange(slot, this.props); + } else if (propsChange) { + this._slotHandler?.propsChange(slot, this.props); + } + } + + ngOnDestroy(): void { + this._slotHandler?.destroy(); + this._slotHandler = undefined; + } +} diff --git a/angular/lib/src/lib/slotDefault.directive.ts b/angular/lib/src/lib/slotDefault.directive.ts new file mode 100644 index 0000000000..3befbd7251 --- /dev/null +++ b/angular/lib/src/lib/slotDefault.directive.ts @@ -0,0 +1,15 @@ +import type {WritableSignal} from '@amadeus-it-group/tansu'; +import type {OnInit} from '@angular/core'; +import {Directive, Input, TemplateRef, inject} from '@angular/core'; +import type {SlotContent} from './slot.directive'; + +@Directive({selector: '[auSlotDefault]', standalone: true}) +export class SlotDefaultDirective implements OnInit { + @Input() auSlotDefault: WritableSignal<{slotDefault?: SlotContent}>; + + templateRef = inject(TemplateRef); + + ngOnInit(): void { + this.auSlotDefault.update((value) => ({...value, slotDefault: this.templateRef})); + } +} diff --git a/angular/lib/src/lib/transition/use.directive.ts b/angular/lib/src/lib/transition/use.directive.ts new file mode 100644 index 0000000000..308c4610c0 --- /dev/null +++ b/angular/lib/src/lib/transition/use.directive.ts @@ -0,0 +1,49 @@ +import type {OnChanges, OnDestroy, SimpleChanges} from '@angular/core'; +import {Directive, ElementRef, inject, Input} from '@angular/core'; +import type {Directive as AgnosUIDirective} from '@agnos-ui/core'; + +@Directive({ + standalone: true, + selector: '[auUse]', +}) +export class UseDirective implements OnChanges, OnDestroy { + @Input('auUse') + use: AgnosUIDirective | undefined; + + @Input('auUseParams') + params: T | undefined; + + private _ref = inject(ElementRef); + + private _directive: AgnosUIDirective | undefined; + private _directiveInstance?: ReturnType>; + + async ngOnChanges(changes: SimpleChanges): Promise { + if (this.use !== this._directive) { + this._directiveInstance?.destroy?.(); + this._directiveInstance = undefined; + const directive = this.use; + this._directive = directive; + if (directive) { + // waiting here is necessary to avoid ExpressionChangedAfterItHasBeenCheckedError + // in case calling the directive changes variables used in the template + await Promise.resolve(); + // checks that the directive did not change while waiting: + if (directive === this._directive && !this._directiveInstance) { + this._directiveInstance = directive(this._ref.nativeElement, this.params as T); + } + } + } else if (changes['params']) { + // waiting here is necessary to avoid ExpressionChangedAfterItHasBeenCheckedError + // in case calling the directive changes variables used in the template + await Promise.resolve(); + this._directiveInstance?.update?.(this.params as T); + } + } + + ngOnDestroy(): void { + this._directiveInstance?.destroy?.(); + this._directiveInstance = undefined; + this._directive = undefined; + } +} diff --git a/angular/lib/src/lib/utils.ts b/angular/lib/src/lib/utils.ts new file mode 100644 index 0000000000..0ba2c87c6f --- /dev/null +++ b/angular/lib/src/lib/utils.ts @@ -0,0 +1,14 @@ +import type {SubscribableStore} from '@amadeus-it-group/tansu'; +import type {SimpleChanges} from '@angular/core'; + +export function patchSimpleChanges(patchFn: (obj: any) => void, changes: SimpleChanges) { + const obj: any = {}; + for (const [key, simpleChange] of Object.entries(changes)) { + if (simpleChange !== undefined) { + obj[key] = simpleChange.currentValue; + } + } + patchFn(obj); +} + +export type ExtractStoreType = T extends SubscribableStore ? U : never; diff --git a/angular/lib/src/public-api.ts b/angular/lib/src/public-api.ts new file mode 100644 index 0000000000..f9db120db5 --- /dev/null +++ b/angular/lib/src/public-api.ts @@ -0,0 +1,13 @@ +/* + * Public API Surface of agnos-ui-angular + */ + +export * from './lib/agnos-ui-angular.module'; +export * from './lib/rating/rating.component'; +export * from './lib/select/select.component'; +export * from './lib/transition/use.directive'; +export * from './lib/pagination/pagination.component'; +export * from './lib/slot.directive'; +export * from './lib/modal/modal.service'; +export * from './lib/modal/modal.component'; +export * from './lib/alert/alert.component'; diff --git a/angular/lib/src/test.ts b/angular/lib/src/test.ts new file mode 100644 index 0000000000..4172e28480 --- /dev/null +++ b/angular/lib/src/test.ts @@ -0,0 +1,9 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js'; +import 'zone.js/testing'; +import {getTestBed} from '@angular/core/testing'; +import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); diff --git a/angular/lib/tsconfig.lib.dev.json b/angular/lib/tsconfig.lib.dev.json new file mode 100644 index 0000000000..ca0d5e69d6 --- /dev/null +++ b/angular/lib/tsconfig.lib.dev.json @@ -0,0 +1,12 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/angular/lib/tsconfig.lib.json b/angular/lib/tsconfig.lib.json new file mode 100644 index 0000000000..e976f80da0 --- /dev/null +++ b/angular/lib/tsconfig.lib.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.dev.json", + "compilerOptions": { + "paths": { + "@agnos-ui/core": ["@agnos-ui/core"] + } + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/angular/lib/tsconfig.lib.prod.json b/angular/lib/tsconfig.lib.prod.json new file mode 100644 index 0000000000..0e5d2df3ac --- /dev/null +++ b/angular/lib/tsconfig.lib.prod.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/angular/lib/tsconfig.spec.json b/angular/lib/tsconfig.spec.json new file mode 100644 index 0000000000..3070c5bc22 --- /dev/null +++ b/angular/lib/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "types": ["jasmine"] + }, + "files": ["src/test.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/angular/package.json b/angular/package.json new file mode 100644 index 0000000000..ff46681bde --- /dev/null +++ b/angular/package.json @@ -0,0 +1,51 @@ +{ + "name": "@agnos-ui/angular", + "private": true, + "scripts": { + "ng": "ng", + "dev": "ng serve", + "build": "npm run build:lib && npm run build:demo", + "build:lib": "ng build lib", + "build:demo": "ng build demo", + "build:copy": "node ../demo/scripts/copy.mjs angular", + "watch": "ng build --watch --configuration development", + "preview": "node ./scripts/preview.cjs dist/demo --port 4200 --single", + "tdd": "npm run test:lib --watch", + "test": "npm run test:lib", + "test:lib": "ng test lib", + "test:demo": "ng test demo" + }, + "dependencies": { + "@angular/animations": "^16.1.3", + "@angular/common": "^16.1.3", + "@angular/compiler": "^16.1.3", + "@angular/core": "^16.1.3", + "@angular/forms": "^16.1.3", + "@angular/platform-browser": "^16.1.3", + "@angular/platform-browser-dynamic": "^16.1.3", + "@angular/router": "^16.1.3", + "rxjs": "^7.8.1", + "tslib": "^2.6.0", + "zone.js": "^0.13.1" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^16.1.3", + "@angular-eslint/eslint-plugin": "^16.0.3", + "@angular-eslint/eslint-plugin-template": "^16.0.3", + "@angular-eslint/template-parser": "^16.0.3", + "@angular/cli": "^16.1.3", + "@angular/compiler-cli": "^16.1.3", + "@types/jasmine": "^4.3.5", + "@types/webpack-env": "^1.18.1", + "jasmine-core": "^5.0.1", + "karma": "^6.4.2", + "karma-chrome-launcher": "^3.2.0", + "karma-coverage": "^2.2.1", + "karma-jasmine": "^5.1.0", + "karma-jasmine-html-reporter": "^2.1.0", + "ng-packagr": "^16.1.0", + "raw-loader": "^4.0.2", + "sirv-cli": "^2.0.2", + "typescript": "~5.1.6" + } +} diff --git a/angular/scripts/preview.cjs b/angular/scripts/preview.cjs new file mode 100644 index 0000000000..38d22f98c1 --- /dev/null +++ b/angular/scripts/preview.cjs @@ -0,0 +1,17 @@ +const http = require('http'); +const originalCreateServer = http.createServer; +http.createServer = (fn) => + originalCreateServer((req, res) => { + if (req.url.startsWith('/angular/samples/')) { + req.originalUrl = req.url; + req.url = req.url.replace('/angular/samples/', '/'); + fn(req, res); + } else { + res.writeHead(302, { + location: '/angular/samples/', + }); + res.end(); + } + }); + +require('sirv-cli/bin'); diff --git a/angular/tsconfig.json b/angular/tsconfig.json new file mode 100644 index 0000000000..709286bc5a --- /dev/null +++ b/angular/tsconfig.json @@ -0,0 +1,36 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../tsconfig.json", + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "noEmit": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "strictPropertyInitialization": false, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "es2020", + "lib": ["es2020", "dom"], + "useDefineForClassFields": false, + "paths": { + "@agnos-ui/core": ["./core/lib"], + "@agnos-ui/angular": ["./angular/lib/src/public-api"] + } + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/api-extractor.json b/api-extractor.json new file mode 100644 index 0000000000..c673f28a4f --- /dev/null +++ b/api-extractor.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/dist/dts/index.d.ts", + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "/dist/lib/index.d.ts" + }, + "apiReport": { + "enabled": false + }, + "messages": { + "extractorMessageReporting": { + "ae-missing-release-tag": { + "logLevel": "none" + } + } + }, + "compiler": { + "tsconfigFilePath": "/tsconfig.d.json" + } +} diff --git a/base-po/README.md b/base-po/README.md new file mode 100644 index 0000000000..530bfb9639 --- /dev/null +++ b/base-po/README.md @@ -0,0 +1,39 @@ +# @agnos-ui/base-po + +[![npm](https://img.shields.io/npm/v/@agnos-ui/base-po)](https://www.npmjs.com/package/@agnos-ui/base-po) + +Base class to build page objects for end-to-end tests with Playwright. + +## Installation + +```sh +npm install @agnos-ui/base-po +``` + +## Usage + +```ts +import {BasePO} from '@agnos-ui/base-po'; +import {Locator} from '@playwright/test'; + +export const customComponentSelectors = { + rootComponent: '.custom', + otherContent: '.content-class', +}; + +export class CustomComponentPO extends BasePO { + selectors = {...customComponentSelectors}; + + override getComponentSelector(): string { + return this.selectors.rootComponent; + } + + get locatorOtherContent(): Locator { + return this.locatorRoot.locator(this.selectors.otherContent); + } +} +``` + +## Main features + +Please refer to the documentation included in [the source code](lib/base.po.ts). diff --git a/base-po/api-extractor.json b/base-po/api-extractor.json new file mode 100644 index 0000000000..6e8b44648c --- /dev/null +++ b/base-po/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../api-extractor.json" +} diff --git a/base-po/lib/base.po.ts b/base-po/lib/base.po.ts new file mode 100644 index 0000000000..abf9e2f893 --- /dev/null +++ b/base-po/lib/base.po.ts @@ -0,0 +1,159 @@ +import type {Page, Locator} from '@playwright/test'; + +function isLocator(locator: Page | Locator): locator is Locator { + return 'waitFor' in locator; +} + +/** + * Base class to be extended for page objects. + * The purpose of this architecture is to standardize the way locators are created in the page object, to be able to target precisely the dom elements you want to interact with. + * + * The constructor takes two parameters: + * + * - `container: Page | Locator` is the container locator from which other locators are built + * - `index: number | null = null` is the nth index component found in the container locator + * + * Page objects must usually override the `getComponentSelector()` method to return the html selector of the component + * + * @example + * + * ```html + * + *
    + *
    + * + *
    + *
    + * + *
    + *
    + * ``` + * + * ```typescript + * // component1.po.ts + * export Component1Page extends BasePo { + * getComponentSelector(): string { + * return 'div.component1'; + * } + * } + * + * // component2.po.ts + * export Component2Page extends BasePo { + * getComponentSelector(): string { + * return 'div.component2'; + * } + * + * // Button locator in component2 + * get locatorButton() { + * return this.locatorRoot.locator('button'); + * } + * } + * + * // test.spec.ts + * test('should click', ({page}) => { + * + * // will manage the component1 section + * const component1Po = new Component1Page(page); + * + * // click on the button of the first component2 of component1 + * const firstComponent2Po = new Component2Page(component1Po.locatorRoot); + * await firstComponent2Po.locatorButton.click(); + * + * // click on the button of the second component2 of component1 + * const secondComponent2Po = new Component2Page(component1Po.locatorRoot, 1); + * await secondComponent2Po.locatorButton.click(); + * + * }); + * + * ``` + * + */ +export class BasePO { + #container: Page | Locator; + #index: number | null; + #locatorRoot: Locator | undefined; + + /** + * Playwright page object from which the page object has been created. + * It can be convenient for situation where you can't work (internally) with the locatorRoot + */ + protected _page: Page; + + /** + * + * @param container - Container locator (page if there is no container) where the component is located + * @param index - If you have multiple components **inside the container**, you must provide the index to select the component to work with. You can omit this parameter if you have a single component. + */ + constructor(container: Page | Locator, index: number | null = null) { + this.#container = container; + this.#index = index; + this._page = isLocator(container) ? container.page() : container; + } + + /** + * The root locator of your page object. + * It can be used to be chain with internal locators. + * + * It is built with the provided `container` from the constructor, the `getComponentSelector` (one of them must be provided, at least) and the optional `index` parameter from the contructor. + * + * @example + * + * ```typescript + * + * // Button locator in this component + * get locatorButton() { + * return this.locatorRoot().locator('button').nth(0); + * } + * ``` + */ + get locatorRoot(): Locator { + let locatorRoot = this.#locatorRoot; + if (!locatorRoot) { + const componentSelector = this.getComponentSelector(); + let tmpLocatorRoot = this.#container; + + if (componentSelector) { + tmpLocatorRoot = tmpLocatorRoot.locator(componentSelector); + + // Use the nth engine : https://playwright.dev/docs/selectors#n-th-element-selector + const index = this.#index; + if (index !== null) { + tmpLocatorRoot = tmpLocatorRoot.nth(index); + } + } + + locatorRoot = isLocator(tmpLocatorRoot) ? tmpLocatorRoot : tmpLocatorRoot.locator('html'); + this.#locatorRoot = locatorRoot; + } + return locatorRoot; + } + + /** + * Returns the root selector for the component + * This method must be usually overriden to return the css selector of your component + * + * @example + * + * ```typescript + * getComponentSelector(): string { + * return 'app-component'; + * } + * ``` + * Will target the html component: + * ```html + * ... + * ``` + */ + getComponentSelector(): string { + return ''; + } + + /** + * Wait for the locatorRoot to be displayed in the page + */ + async waitLoaded() { + if (isLocator(this.locatorRoot)) { + await this.locatorRoot.waitFor(); + } + } +} diff --git a/base-po/lib/index.ts b/base-po/lib/index.ts new file mode 100644 index 0000000000..1e846a3f84 --- /dev/null +++ b/base-po/lib/index.ts @@ -0,0 +1 @@ +export * from './base.po'; diff --git a/base-po/package.json b/base-po/package.json new file mode 100644 index 0000000000..2306c71a71 --- /dev/null +++ b/base-po/package.json @@ -0,0 +1,32 @@ +{ + "name": "@agnos-ui/base-po", + "description": "Base class to build page objects for end-to-end tests with Playwright.", + "keywords": [ + "playwright", + "page-object", + "e2e", + "testing" + ], + "main": "dist/lib/index.js", + "module": "dist/lib/index.mjs", + "types": "dist/lib/index.d.ts", + "scripts": { + "build": "npm run build:rollup && npm run build:dts && npm run build:api-extractor", + "build:rollup": "tsc && vite build -c vite.config.ts", + "build:dts": "tsc -p tsconfig.d.json", + "build:api-extractor": "api-extractor run" + }, + "peerDependencies": { + "@playwright/test": "*" + }, + "files": [ + "dist/lib" + ], + "license": "MIT", + "bugs": "https://github.com/AmadeusITGroup/AgnosUI/issues", + "repository": { + "type": "git", + "url": "https://github.com/AmadeusITGroup/AgnosUI.git", + "directory": "base-po" + } +} diff --git a/base-po/tsconfig.d.json b/base-po/tsconfig.d.json new file mode 100644 index 0000000000..276a4ad85e --- /dev/null +++ b/base-po/tsconfig.d.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist/dts" + }, + "include": ["lib"] +} diff --git a/base-po/tsconfig.json b/base-po/tsconfig.json new file mode 100644 index 0000000000..7a81bccf77 --- /dev/null +++ b/base-po/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "exclude": ["dist"] +} diff --git a/base-po/vite.config.ts b/base-po/vite.config.ts new file mode 100644 index 0000000000..14eb4ad903 --- /dev/null +++ b/base-po/vite.config.ts @@ -0,0 +1,19 @@ +import {peerDependencies} from './package.json'; +import path from 'path'; +import {defineConfig} from 'vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + build: { + lib: { + entry: 'lib/index', + fileName: 'index', + formats: ['es', 'cjs'], + }, + rollupOptions: { + external: [...Object.keys(peerDependencies)], + }, + emptyOutDir: true, + outDir: path.join(__dirname, 'dist/lib'), + }, +}); diff --git a/common/demo.scss b/common/demo.scss new file mode 100644 index 0000000000..af05270f5b --- /dev/null +++ b/common/demo.scss @@ -0,0 +1,70 @@ +body { + min-height: 100vh; +} + +main { + display: flex; + flex-wrap: nowrap; +} + +.sample-links { + column-count: 1; + + @media (min-width: 576px) { + column-count: 2; + } + @media (min-width: 768px) { + column-count: 3; + } + @media (min-width: 992px) { + column-count: 5; + } + @media (min-width: 1200px) { + column-count: 6; + } +} + +.agnos-ui { + padding: 0; + + &-logo { + height: 30px; + } +} + +.rating-custom { + .star { + position: relative; + display: inline-block; + font-size: 300%; + color: #d3d3d3; + } + + .full { + color: red; + } + + .half { + position: absolute; + display: inline-block; + overflow: hidden; + color: red; + } + + .star-2 { + font-size: 150%; + color: #b0c4de; + } + + .filled { + color: #1e90ff; + } + + .bad { + color: #deb0b0; + } + + .filled.bad { + color: #ff1e1e; + } +} diff --git a/common/utils.ts b/common/utils.ts new file mode 100644 index 0000000000..6c5b0d2d0d --- /dev/null +++ b/common/utils.ts @@ -0,0 +1,48 @@ +import {computed, readable} from '@amadeus-it-group/tansu'; + +export const hash$ = + typeof window === 'undefined' + ? readable('') + : readable('', (set) => { + function updateFromHash() { + const hash = location.hash; + set(hash ? hash.substring(1) : ''); + } + + window.addEventListener('hashchange', updateFromHash); + updateFromHash(); + return () => window.removeEventListener('hashchange', updateFromHash); + }); + +export const hashObject$ = computed(() => { + let hashString = hash$().split('#').at(-1); + if (!hashString || hashString.at(0) !== '{') { + hashString = '{}'; + } + const {config = {}, props = {}} = JSON.parse(decodeURIComponent(hashString)); + return {config, props}; +}); + +/** + * Return undefined if the object is empty, the object otherwise + */ +function undefinedIfEmpty(object: object | undefined) { + return object ? (Object.entries(object).filter(([, value]) => value !== undefined).length ? object : undefined) : undefined; +} + +/** + * Update the hash url + * @param type Type of value to be update + * @param key + * @param value any value, or undefined to remove the key + */ +export function updateHashValue(type: 'config' | 'props', key: string, value: any) { + const hashObj = structuredClone(hashObject$()); + const hashObjForType: Record = hashObj[type] ?? {}; + hashObjForType[key] = value; + hashObj['config'] = undefinedIfEmpty(hashObj['config']); + hashObj['props'] = undefinedIfEmpty(hashObj['props']); + const hashString = JSON.stringify(undefinedIfEmpty(hashObj)); + // TODO: prevent the navigation to be recorded in the history. + location.hash = hashString ? `#${hashString}` : '#'; +} diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000000..03837c11e4 --- /dev/null +++ b/core/README.md @@ -0,0 +1,15 @@ +# @agnos-ui/core + +[![npm](https://img.shields.io/npm/v/@agnos-ui/core)](https://www.npmjs.com/package/@agnos-ui/core) + +[AgnosUI](https://amadeusitgroup.github.io/AgnosUI/latest/) is a framework-agnostic widget library with adapters for multiple frameworks: + +- [Angular](https://www.npmjs.com/package/@agnos-ui/angular) +- [React](https://www.npmjs.com/package/@agnos-ui/react) +- [Svelte](https://www.npmjs.com/package/@agnos-ui/svelte) + +This `@agnos-ui/core` package contains the framework-agnostic common code used by the above framework adapters. + +Please check [our demo site](https://amadeusitgroup.github.io/AgnosUI/latest/) to see all the available widgets and how to use them. + +Unless you want to develop an adapter for a framework, you probably do not need to use `@agnos-ui/core` directly. Please refer to one of the framework-specific packages. diff --git a/core/api-extractor.json b/core/api-extractor.json new file mode 100644 index 0000000000..6e8b44648c --- /dev/null +++ b/core/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../api-extractor.json" +} diff --git a/core/lib/alert.spec.ts b/core/lib/alert.spec.ts new file mode 100644 index 0000000000..d1cfb71fbe --- /dev/null +++ b/core/lib/alert.spec.ts @@ -0,0 +1,94 @@ +import {beforeEach, describe, expect, test} from 'vitest'; +import {createAlert} from './alert'; +import type {AlertWidget} from './alert'; +import type {WidgetState} from './types'; + +const promiseWithResolve = () => { + let resolve: (value: void | Promise) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return {promise, resolve: resolve!}; +}; + +describe(`Alert`, () => { + let alert: AlertWidget; + let state: WidgetState; + + beforeEach(() => { + alert = createAlert(); + alert.state$.subscribe((newState) => { + state = newState; + }); + }); + + test(`should create alert with a default state`, () => { + expect(state).toEqual({ + dismissible: true, + slotDefault: undefined, + slotStructure: undefined, + type: 'primary', + visible: true, + hidden: false, + ariaCloseButtonLabel: 'Close', + }); + }); + + test(`should close on method call`, () => { + const expectedState = state; + expect(expectedState.visible).toBe(true); + alert.api.close(); + expectedState.visible = false; + expectedState.hidden = true; + expect(state).toEqual(expectedState); + }); + + test(`should open on method call`, () => { + alert.patch({visible: false}); + const expectedState = state; + expect(expectedState.visible).toBe(false); + alert.api.open(); + expectedState.visible = true; + expectedState.hidden = false; + expect(state).toEqual(expectedState); + }); + + test('should emit events on change of the visibility', async () => { + let onShownCounter = 0; + let onHiddenCounter = 0; + let onVisibleChangeCounter = 0; + let promiseOnShown = promiseWithResolve(); + let promiseOnHidden = promiseWithResolve(); + const element = document.createElement('div'); + element.innerHTML = '
    body
    '; + const alertEvents = createAlert({ + type: 'danger', + onShown() { + promiseOnShown.resolve(); + onShownCounter++; + promiseOnShown = promiseWithResolve(); + }, + onHidden() { + promiseOnHidden.resolve(); + onHiddenCounter++; + promiseOnHidden = promiseWithResolve(); + }, + onVisibleChange(event) { + onVisibleChangeCounter++; + }, + }); + alertEvents.directives.transitionDirective(element); + + alertEvents.api.close(); + await promiseOnHidden.promise; + expect(onVisibleChangeCounter).toBe(1); + expect(onShownCounter).toBe(0); + expect(onHiddenCounter).toBe(1); + + alertEvents.api.open(); + await promiseOnShown.promise; + expect(onVisibleChangeCounter).toBe(2); + expect(onShownCounter).toBe(1); + expect(onHiddenCounter).toBe(1); + }); +}); diff --git a/core/lib/alert.ts b/core/lib/alert.ts new file mode 100644 index 0000000000..4bb9966263 --- /dev/null +++ b/core/lib/alert.ts @@ -0,0 +1,177 @@ +import type {ConfigValidator, PropsConfig} from './services'; +import {bindDirectiveNoArg, stateStores, typeBoolean, typeString, writablesForProps} from './services'; +import type {TransitionFn} from './transitions'; +import {createTransition} from './transitions'; +import {fadeTransition} from './transitions/bootstrap'; +import type {Directive, SlotContent, Widget, WidgetSlotContext} from './types'; +import {noop} from './utils'; + +export type AlertContext = WidgetSlotContext; + +export interface AlertCommonPropsAndState { + /** + * If `true`, alert can be dismissed by the user. + * The close button (×) will be displayed and you can be notified of the event with the (close) output. + */ + dismissible: boolean; + + /** + * Type of the alert. + * The are the following types: 'success', 'info', 'warning', 'danger', 'primary', 'secondary', 'light' and 'dark'. + */ + type: string; + + /** + * Template for the alert content + */ + slotDefault: SlotContent; + + /** + * Global template for the alert component + */ + slotStructure: SlotContent; + + /** + * If `true` the alert is visible to the user + */ + visible: boolean; + + /** + * Accessibility close button label + */ + ariaCloseButtonLabel: string; +} + +export interface AlertState extends AlertCommonPropsAndState { + hidden: boolean; +} + +export interface AlertProps extends AlertCommonPropsAndState { + /** + * Callback called when the alert visibility changed. + */ + onVisibleChange: (visible: boolean) => void; + + /** + * Callback called when the alert is hidden. + */ + onHidden: () => void; + + /** + * Callback called when the alert is shown. + */ + onShown: () => void; + + /** + * The transition function will be executed when the alert is displayed or hidden. + * + * Depending on the value of {@link AlertProps.animationOnInit}, the animation can be optionally skipped during the showing process. + */ + transition: TransitionFn; + + /** + * If `true`, alert opening will be animated. + * + * Animation is triggered when the `.open()` function is called + * or the visible prop is changed + */ + animationOnInit: boolean; + /** + * If `true`, alert closing will be animated. + * + * Animation is triggered when clicked on the close button (×), + * via the `.close()` function or the visible prop is changed + */ + animation: boolean; +} + +export interface AlertApi { + /** + * Triggers alert closing programmatically (same as clicking on the close button (×)). + */ + close(): void; + + /** + * Triggers the alert to be displayed for the user. + */ + open(): void; +} + +export interface AlertDirectives { + transitionDirective: Directive; +} + +export type AlertWidget = Widget; + +const defaultConfig: AlertProps = { + visible: true, + dismissible: true, + type: 'primary', + ariaCloseButtonLabel: 'Close', + onVisibleChange: noop, + onShown: noop, + onHidden: noop, + slotStructure: undefined, + slotDefault: undefined, + animation: true, + animationOnInit: false, + transition: fadeTransition, +}; + +export function getAlertDefaultConfig() { + return {...defaultConfig}; +} + +const configValidator: ConfigValidator = { + dismissible: typeBoolean, + type: typeString, +}; + +export function createAlert(config?: PropsConfig): AlertWidget { + const [ + { + transition$, + animationOnInit$, + animation$, + visible$: requestedVisible$, + onVisibleChange$, + onHidden$, + onShown$, + + ...stateProps + }, + patch, + ] = writablesForProps(defaultConfig, config, configValidator); + + const transition = createTransition({ + transition: transition$, + visible: requestedVisible$, + animation: animation$, + animationOnInit: animationOnInit$, + onVisibleChange: onVisibleChange$, + onHidden: onHidden$, + onShown: onShown$, + }); + const close = () => { + patch({visible: false}); + }; + + const open = () => { + patch({visible: true}); + }; + + const visible$ = transition.stores.visible$; + const hidden$ = transition.stores.hidden$; + return { + ...stateStores({...stateProps, visible$, hidden$}), + patch, + api: { + open, + close, + }, + directives: { + transitionDirective: bindDirectiveNoArg(transition.directives.directive), + }, + actions: {}, + }; +} diff --git a/core/lib/config.spec.ts b/core/lib/config.spec.ts new file mode 100644 index 0000000000..94eed13f1e --- /dev/null +++ b/core/lib/config.spec.ts @@ -0,0 +1,63 @@ +import {describe, expect, test} from 'vitest'; +import type {Partial2Levels} from './config'; +import {createWidgetsConfig} from './config'; + +describe('defaultConfig', () => { + test(`Basic functionalities`, () => { + type CfgType = {w: {a: number; b: number; c: number}}; + const parentCfg$ = createWidgetsConfig(); + parentCfg$.set({w: {a: 1}}); + const childCfg$ = createWidgetsConfig(parentCfg$); + const grandChildCfg$ = createWidgetsConfig(childCfg$); + const grandChildValues: Partial2Levels[] = []; + grandChildCfg$.subscribe((value) => grandChildValues.push(value)); + const otherChildCfg$ = createWidgetsConfig(parentCfg$); + const otherChildValues: Partial2Levels[] = []; + otherChildCfg$.subscribe((value) => otherChildValues.push(value)); + expect(grandChildValues).toEqual([{w: {a: 1}}]); + expect(otherChildValues).toEqual([{w: {a: 1}}]); + childCfg$.set({w: {b: 2}}); + expect(grandChildValues).toEqual([{w: {a: 1}}, {w: {a: 1, b: 2}}]); + expect(otherChildValues).toEqual([{w: {a: 1}}]); + otherChildCfg$.set({w: {c: 3}}); + expect(grandChildValues).toEqual([{w: {a: 1}}, {w: {a: 1, b: 2}}]); + expect(otherChildValues).toEqual([{w: {a: 1}}, {w: {a: 1, c: 3}}]); + grandChildCfg$.set({w: {b: 4}}); + expect(grandChildValues).toEqual([{w: {a: 1}}, {w: {a: 1, b: 2}}, {w: {a: 1, b: 4}}]); + expect(otherChildValues).toEqual([{w: {a: 1}}, {w: {a: 1, c: 3}}]); + grandChildCfg$.set({w: {a: 5, b: undefined}}); + expect(grandChildValues).toEqual([{w: {a: 1}}, {w: {a: 1, b: 2}}, {w: {a: 1, b: 4}}, {w: {a: 5, b: 2}}]); + expect(otherChildValues).toEqual([{w: {a: 1}}, {w: {a: 1, c: 3}}]); + }); + + test(`adaptParentConfig`, () => { + type CfgType = {w: {a: number; b: number; c: number}}; + const parentCfg$ = createWidgetsConfig(); + const parentCfgValues: Partial2Levels[] = []; + parentCfg$.subscribe((value) => parentCfgValues.push(value)); + const childCfg$ = createWidgetsConfig(parentCfg$, (parentCfg) => { + if (parentCfg.w?.a) { + parentCfg.w.a++; + } else { + if (!parentCfg.w) { + parentCfg.w = {}; + } + parentCfg.w.a = 0; + } + return parentCfg; + }); + const childCfgValues: Partial2Levels[] = []; + childCfg$.subscribe((value) => childCfgValues.push(value)); + expect(parentCfgValues).toEqual([{}]); + expect(childCfgValues).toEqual([{w: {a: 0}}]); + parentCfg$.set({w: {a: 2}}); + expect(parentCfgValues).toEqual([{}, {w: {a: 2}}]); + expect(childCfgValues).toEqual([{w: {a: 0}}, {w: {a: 3}}]); + childCfg$.set({w: {a: 9}}); + expect(parentCfgValues).toEqual([{}, {w: {a: 2}}]); + expect(childCfgValues).toEqual([{w: {a: 0}}, {w: {a: 3}}, {w: {a: 9}}]); + childCfg$.set({w: {}}); + expect(parentCfgValues).toEqual([{}, {w: {a: 2}}]); + expect(childCfgValues).toEqual([{w: {a: 0}}, {w: {a: 3}}, {w: {a: 9}}, {w: {a: 3}}]); + }); +}); diff --git a/core/lib/config.ts b/core/lib/config.ts new file mode 100644 index 0000000000..f4257c693a --- /dev/null +++ b/core/lib/config.ts @@ -0,0 +1,84 @@ +import type {ReadableSignal, WritableSignal} from '@amadeus-it-group/tansu'; +import {asReadable, computed, writable} from '@amadeus-it-group/tansu'; +import type {ModalProps} from './modal/modal'; +import type {AlertProps} from './alert'; +import type {PaginationProps} from './pagination'; +import type {RatingProps} from './rating'; +import type {SelectProps} from './select'; +import {identity} from './utils'; + +export type Partial2Levels = Partial<{ + [Level1 in keyof T]: Partial; +}>; + +export type WidgetsConfigStore = WritableSignal> & { + own$: WritableSignal>; + parent$: undefined | WritableSignal>; + adaptedParent$: undefined | ReadableSignal>; +}; + +/** + * Merges source object into destination object, up to the provided number of levels. + * @param destination - destination object + * @param source - source object + * @param levels - number of levels to merge + * @returns the destination object in most cases, or the source in some cases (if the source is not undefined and either levels is smaller than 1 + * or the source is not an object) + */ +export const mergeInto = (destination: T, source: T | undefined, levels = Infinity): T => { + if (source !== undefined) { + if (typeof source === 'object' && source && levels >= 1) { + for (const key of Object.keys(source) as (keyof T)[]) { + destination[key] = mergeInto(destination[key] ?? {}, source[key] as any, levels - 1); + } + } else { + return source; + } + } + return destination; +}; + +/** + * Creates a new widgets default configuration store, optionally inheriting from a parent store, and containing + * its own set of widgets configuration properties that override the same properties form the parent configuration. + * + * @remarks + * The resulting store has a value computed from the parent store in two steps: + * - first step: the parent configuration is transformed by the adaptParentConfig function (if specified). + * If adaptParentConfig is not specified, this step is skipped. + * - second step: the configuration from step 1 is merged (2-levels deep) with the own$ store. The own$ store initially contains + * an empty object (i.e. no property from the parent is overridden). It can be changed by calling set on the store returned by this function. + * + * @param parent$ - optional parent widgets default configuration store. + * @param adaptParentConfig - optional function that receives a 2-levels copy of the widgets default configuration + * from parent$ (or an empty object if parent$ is not specified) and returns the widgets default configuration to be used. + * It is called only if the configuration is needed, and was not yet computed for the current value of the parent configuration. + * It is called in a tansu reactive context, so it can use any tansu store and will be called again if those stores change. + * @returns the resulting widgets default configuration store, which contains 3 additional properties that are stores: + * parent$, adaptedParent$ (containing the value computed after the first step), and own$ (that contains only overridding properties). + * The resulting store is writable, its set function is actually the set function of the own$ store. + */ +export const createWidgetsConfig = ( + parent$?: WidgetsConfigStore | undefined, + adaptParentConfig: (config: Partial2Levels) => Partial2Levels = identity +): WidgetsConfigStore => { + const own$ = writable({} as Partial2Levels); + const adaptedParent$ = adaptParentConfig === identity ? parent$ : computed(() => adaptParentConfig(mergeInto({}, parent$?.(), 2))); + return asReadable( + computed(() => mergeInto(mergeInto({}, adaptedParent$?.(), 2), own$(), 2)), + { + ...own$, + own$, + adaptedParent$, + parent$, + } + ); +}; + +export interface WidgetsConfig { + pagination: PaginationProps; + rating: RatingProps; + select: SelectProps; + modal: ModalProps; + alert: AlertProps; +} diff --git a/core/lib/index.ts b/core/lib/index.ts new file mode 100644 index 0000000000..bab84e4bee --- /dev/null +++ b/core/lib/index.ts @@ -0,0 +1,10 @@ +export * from './types'; +export * from './select'; +export * from './services'; +export * from './transitions'; +export * from './rating'; +export * from './pagination'; +export * from './pagination.utils'; +export * from './config'; +export * from './modal/modal'; +export * from './alert'; diff --git a/core/lib/modal/modal.spec.ts b/core/lib/modal/modal.spec.ts new file mode 100644 index 0000000000..95d0e121eb --- /dev/null +++ b/core/lib/modal/modal.spec.ts @@ -0,0 +1,151 @@ +import {describe, expect, test, beforeEach} from 'vitest'; +import {createModal, modalCloseButtonClick, modalOutsideClick} from './modal'; + +describe('modal', () => { + const noopTransition = async () => {}; + + let testArea: HTMLElement; + + beforeEach(() => { + testArea = document.body.appendChild(document.createElement('div')); + return () => { + testArea.parentElement?.removeChild(testArea); + }; + }); + + test('inert and scrollbar features', async () => { + testArea.innerHTML = ` +
    +
    + `; + expect(document.body.style.overflow).not.toBe('hidden'); + const modal = createModal({ + modalTransition: noopTransition, + }); + const modalElement = document.getElementById('modalElement')!; + const directive = modal.directives.modalDirective(modalElement); + const promise = modal.api.open(); + expect(modal.stores.modalElement$()).toBe(modalElement); + expect(document.getElementById('previousElement')!.hasAttribute('inert')).toBeTruthy(); + expect(document.body.style.overflow).toBe('hidden'); + expect(modal.stores.visible$()).toBe(true); + expect(modal.stores.hidden$()).toBe(false); + modal.api.close(); + expect(modal.stores.visible$()).toBe(false); + expect(modal.stores.hidden$()).toBe(false); + await promise; + expect(modal.stores.hidden$()).toBe(true); + expect(modal.stores.visible$()).toBe(false); + directive?.destroy?.(); + expect(document.getElementById('previousElement')!.hasAttribute('inert')).toBeFalsy(); + expect(document.body.style.overflow).not.toBe('hidden'); + }); + + test('close on close method call', async () => { + const element = {} as HTMLElement; + const modal = createModal({ + modalTransition: noopTransition, + }); + const directive = modal.directives.modalDirective(element); + const promise = modal.api.open(); + const resultObject = {}; + modal.api.close(resultObject); + const result = await promise; + expect(result).toBe(resultObject); + directive?.destroy?.(); + }); + + test('close on close button click', async () => { + const element = {} as HTMLElement; + const modal = createModal({ + modalTransition: noopTransition, + }); + const directive = modal.directives.modalDirective(element); + const promise = modal.api.open(); + modal.actions.closeButtonClick({} as any as MouseEvent); + const result = await promise; + expect(result).toBe(modalCloseButtonClick); + directive?.destroy?.(); + }); + + test('close on outside click', async () => { + const element = {} as HTMLElement; + const modal = createModal({ + modalTransition: noopTransition, + closeOnOutsideClick: true, + }); + const directive = modal.directives.modalDirective(element); + const promise = modal.api.open(); + modal.actions.modalClick({currentTarget: element, target: element} as any as MouseEvent); + const result = await promise; + expect(result).toBe(modalOutsideClick); + directive?.destroy?.(); + }); + + test('do not close on outside click', async () => { + const element = {} as HTMLElement; + const modal = createModal({ + modalTransition: noopTransition, + closeOnOutsideClick: false, + }); + const directive = modal.directives.modalDirective(element); + let settled = false; + const promise = modal.api.open(); + // should not close the modal: + modal.actions.modalClick({currentTarget: element, target: element} as any as MouseEvent); + promise.finally(() => (settled = true)); + await new Promise((resolve) => setTimeout(resolve, 100)); + directive?.destroy?.(); + expect(settled).toBe(false); + }); + + test('do not close when close is canceled from onBeforeClose', async () => { + let onBeforeCloseCalled = 0; + const element = {} as HTMLElement; + const modal = createModal({ + modalTransition: noopTransition, + onBeforeClose(event) { + onBeforeCloseCalled++; + event.cancel = true; + }, + }); + const directive = modal.directives.modalDirective(element); + let settled = false; + const promise = modal.api.open(); + expect(onBeforeCloseCalled).toBe(0); + // should not close the modal: + modal.api.close(null); + expect(onBeforeCloseCalled).toBe(1); + promise.finally(() => (settled = true)); + await new Promise((resolve) => setTimeout(resolve, 100)); + directive?.destroy?.(); + expect(settled).toBe(false); + expect(onBeforeCloseCalled).toBe(1); + }); + + test('change result from onBeforeClose', async () => { + let onBeforeCloseCalled = 0; + let resultInOnBeforeClose = null; + const secondResult = {info: 'secondResult'}; + const element = {} as HTMLElement; + const modal = createModal({ + modalTransition: noopTransition, + onBeforeClose(event) { + onBeforeCloseCalled++; + resultInOnBeforeClose = event.result; + event.result = secondResult; + }, + }); + const directive = modal.directives.modalDirective(element); + const promise = modal.api.open(); + expect(onBeforeCloseCalled).toBe(0); + const firstResult = {info: 'firstResult'}; + modal.api.close(firstResult); + expect(onBeforeCloseCalled).toBe(1); + expect(resultInOnBeforeClose).toBe(firstResult); + const result = await promise; + expect(result).toBe(secondResult); + directive?.destroy?.(); + expect(onBeforeCloseCalled).toBe(1); + }); +}); diff --git a/core/lib/modal/modal.ts b/core/lib/modal/modal.ts new file mode 100644 index 0000000000..6264bbf8bd --- /dev/null +++ b/core/lib/modal/modal.ts @@ -0,0 +1,441 @@ +import {computed, readable} from '@amadeus-it-group/tansu'; +import type {PropsConfig} from '../services'; +import { + sliblingsInert, + bindDirective, + bindDirectiveNoArg, + directiveSubscribe, + mergeDirectives, + portal, + registrationArray, + stateStores, + writablesForProps, +} from '../services'; +import type {TransitionFn} from '../transitions'; +import {createTransition} from '../transitions'; +import {fadeTransition} from '../transitions/bootstrap/fade'; +import {promiseFromStore} from '../transitions/utils'; +import type {Widget, Directive, SlotContent, WidgetSlotContext} from '../types'; +import {noop} from '../utils'; +import {removeScrollbars, revertScrollbars} from './scrollbars'; + +/** + * Value present in the {@link ModalBeforeCloseEvent.result|result} property of the {@link ModalProps.onBeforeClose|onBeforeClose} event + * and returned by the {@link ModalApi.open|open} method, when the modal is closed by a click inside the viewport but outside the modal. + */ +export const modalOutsideClick = Symbol(); + +/** + * Value present in the {@link ModalBeforeCloseEvent.result|result} property of the {@link ModalProps.onBeforeClose|onBeforeClose} event + * and returned by the {@link ModalApi.open|open} method, when the modal is closed by a click on the close button. + */ +export const modalCloseButtonClick = Symbol(); + +/** + * Context of the modal slots. + */ +export type ModalContext = WidgetSlotContext; + +/** + * Properties of the modal widget that are also in the state of the modal. + */ +export interface ModalCommonPropsAndState { + /** + * Value of the aria-label attribute to put on the close button. + */ + ariaCloseButtonLabel: string; + + /** + * Classes to add on the backdrop DOM element. + */ + backdropClass: string; + + /** + * Whether to display the close button. + */ + closeButton: boolean; + + /** + * Which element should contain the modal and backdrop DOM elements. + * If it is not null, the modal and backdrop DOM elements are moved to the specified container. + * Otherwise, they stay where the widget is located. + */ + container: HTMLElement | null; + + /** + * Classes to add on the modal DOM element. + */ + modalClass: string; + + /** + * Body of the modal. + */ + slotDefault: SlotContent; + + /** + * Footer of the modal. + */ + slotFooter: SlotContent; + + /** + * Header of the modal. The default header includes {@link ModalCommonPropsAndState.slotTitle|slotTitle}. + */ + slotHeader: SlotContent; + + /** + * Structure of the modal. + * The default structure uses {@link ModalCommonPropsAndState.slotHeader|slotHeader}, {@link ModalCommonPropsAndState.slotDefault|slotDefault} and {@link ModalCommonPropsAndState.slotFooter|slotFooter}. + */ + slotStructure: SlotContent; + + /** + * Title of the modal. + */ + slotTitle: SlotContent; + + /** + * Whether the modal should be visible when the transition is completed. + */ + visible: boolean; +} + +/** + * Type of the parameter of {@link ModalProps.onBeforeClose|onBeforeClose}. + */ +export interface ModalBeforeCloseEvent { + /** + * Result of the modal, which is the value passed to the {@link ModalApi.close|close} method + * and later resolved by the promise returned by the {@link ModalApi.open|open} method. + * If needed, it can be changed from the {@link ModalProps.onBeforeClose|onBeforeClose} event handler. + */ + result: any; + + /** + * Whether to cancel the close of the modal. + * It can be changed from the {@link ModalProps.onBeforeClose|onBeforeClose} event handler. + */ + cancel: boolean; +} + +/** + * Properties of the modal widget. + */ +export interface ModalProps extends ModalCommonPropsAndState { + /** + * Whether the modal and its backdrop (if present) should be animated when shown or hidden. + */ + animation: boolean; + + /** + * Whether a backdrop should be created behind the modal. + */ + backdrop: boolean; + + /** + * The transition to use for the backdrop behind the modal (if present). + */ + backdropTransition: TransitionFn; + + /** + * Whether the modal should be closed when clicking on the viewport outside the modal. + */ + closeOnOutsideClick: boolean; + + /** + * The transition to use for the modal. + */ + modalTransition: TransitionFn; + + /** + * Event to be triggered when the modal is about to be closed (i.e. the {@link ModalApi.close|close} method was called). + * + * @param event - event giving access to the argument given to the {@link ModalApi.close|close} method and allowing + * to cancel the close process. + */ + onBeforeClose: (event: ModalBeforeCloseEvent) => void; + + /** + * Event to be triggered when the visible property changes. + * + * @param visible - new value of the visible propery + */ + onVisibleChange: (visible: boolean) => void; + + /** + * Event to be triggered when the transition is completed and the modal is not visible. + */ + onHidden: () => void; + + /** + * Event to be triggered when the transition is completed and the modal is visible. + */ + onShown: () => void; +} + +/** + * State of the modal widget. + */ +export interface ModalState extends ModalCommonPropsAndState { + /** + * Whether the backdrop is fully hidden. This can be true either because {@link ModalProps.backdrop|backdrop} is false or + * because {@link ModalCommonPropsAndState.visible|visible} is false and there is no current transition. + */ + backdropHidden: boolean; + + /** + * Whether the modal is fully hidden. + */ + hidden: boolean; + + /** + * Whether there is an active transition to either display or hide the modal. + */ + transitioning: boolean; + + /** + * DOM element of the modal. + */ + modalElement: HTMLElement | null; +} + +/** + * API of the modal widget. + */ +export interface ModalApi { + /** + * Closes the modal with the given result. + * + * @param result - result of the modal, as passed in the {@link ModalBeforeCloseEvent.result|result} property of the event passed to the + * {@link ModalProps.onBeforeClose|onBeforeClose} event handler (and possibly changed by it) and resolved by the promise returned by the {@link ModalApi.open|open} method. + */ + close(result?: any): void; + + /** + * Opens the modal and returns a promise that is resolved when the modal is closed. + * The resolved value is the result passed to the {@link ModalApi.close|close} method and possibly changed by the + * {@link ModalProps.onBeforeClose|onBeforeClose} event handler + */ + open(): Promise; + + /** + * Method to change some modal properties. + */ + patch: ModalWidget['patch']; +} + +/** + * Actions of the modal widget. + */ +export interface ModalActions { + /** + * Action to be called when the user clicks on the close button. It closes the modal with the {@link modalCloseButtonClick} result. + * @param event - mouse event + */ + closeButtonClick(event: MouseEvent): void; + + /** + * Action to be called when the user clicks on the modal DOM element (which is supposed to have the size of the full viewport). + * If the click is not done on a descendant of the modal DOM element, it is considered to be done outside the modal + * and, depending on the value of the {@link ModalProps.closeOnOutsideClick|closeOnOutsideClick} prop, the modal is or isn't closed + * (with the {@link modalOutsideClick} result). + * @param event - mouse event + */ + modalClick(event: MouseEvent): void; +} + +/** + * Directives of the modal widget. + */ +export interface ModalDirectives { + /** + * Directive to put on the modal DOM element. + */ + modalDirective: Directive; + + /** + * Directive to put on the backdrop DOM element. + */ + backdropDirective: Directive; + + /** + * Portal directive to put on the modal DOM element. + */ + modalPortalDirective: Directive; + + /** + * Portal directive to put on the backdrop DOM element. + */ + backdropPortalDirective: Directive; +} + +/** + * Modal widget. + */ +export type ModalWidget = Widget; + +const defaultConfig: ModalProps = { + animation: true, + ariaCloseButtonLabel: 'Close', + backdrop: true, + backdropClass: '', + backdropTransition: fadeTransition, // TODO: is it ok to depend on bootstrap transition? + closeButton: true, + closeOnOutsideClick: true, + container: typeof window !== 'undefined' ? document.body : null, + modalClass: '', + modalTransition: fadeTransition, // TODO: is it ok to depend on bootstrap transition? + onBeforeClose: noop, + onVisibleChange: noop, + onHidden: noop, + onShown: noop, + slotDefault: undefined, + slotFooter: undefined, + slotHeader: undefined, + slotStructure: undefined, + slotTitle: undefined, + visible: false, +}; + +/** + * Returns a copy of the default modal config. + */ +export function getModalDefaultConfig() { + return {...defaultConfig}; +} + +const modals$ = registrationArray(); +const hasModals$ = computed(() => modals$().length > 0); +const scrollbarsAction$ = computed(() => { + if (hasModals$()) { + removeScrollbars(); + } else { + revertScrollbars(); + } +}); +const modalsAction$ = computed(() => { + scrollbarsAction$(); +}); + +/** + * Creates a new modal widget instance. + * @param config$ - config of the modal, either as a store or as an object containing values or stores. + * @returns a new modal widget instance + */ +export const createModal = (config$?: PropsConfig): ModalWidget => { + const [ + { + animation$, + backdrop$, + backdropTransition$, + closeOnOutsideClick$, + container$, + modalTransition$, + onBeforeClose$, + onVisibleChange$, + onHidden$, + onShown$, + visible$: requestedVisible$, + ...stateProps + }, + patch, + ] = writablesForProps(defaultConfig, config$); + const modalTransition = createTransition({ + transition: modalTransition$, + visible: requestedVisible$, + animation: animation$, + animationOnInit: animation$, + onVisibleChange: onVisibleChange$, + // TODO: for onHidden and onShown, should we combine with information from the backdrop transition? + // (especially in case one of the two transitions takes more time than the other) + onHidden: onHidden$, + onShown: onShown$, + }); + const visible$ = modalTransition.stores.visible$; + const backdropTransition = createTransition({ + transition: backdropTransition$, + visible: requestedVisible$, + animation: animation$, + animationOnInit: animation$, + }); + const transitioning$ = computed(() => modalTransition.stores.transitioning$() || (backdrop$() && backdropTransition.stores.transitioning$())); + const hidden$ = computed(() => !transitioning$() && !visible$()); + const backdropHidden$ = computed(() => !backdrop$() || hidden$()); + let hideResult: any; + + const close = (result: any) => { + hideResult = result; + const beforeCloseEvent: ModalBeforeCloseEvent = { + get result() { + return hideResult; + }, + set result(value: any) { + hideResult = value; + }, + cancel: false, + }; + onBeforeClose$()(beforeCloseEvent); + if (beforeCloseEvent.cancel) { + return; + } + patch({visible: false}); + }; + + const modalPortalDirective = bindDirective( + portal, + computed(() => ({container: container$()})) + ); + const backdropPortalDirective = bindDirective( + portal, + computed(() => ({ + container: container$(), + insertBefore: container$() && modalTransition.stores.element$()?.parentElement === container$() ? modalTransition.stores.element$() : undefined, + })) + ); + const registerModalAction$ = readable(undefined, () => modals$.register(res)); + const action$ = computed(() => { + if (modalTransition.stores.elementPresent$() && !hidden$()) { + registerModalAction$(); + } + modalsAction$(); + }); + + const res: ModalWidget = { + ...stateStores({ + backdropHidden$, + container$, + hidden$, + transitioning$, + visible$, + modalElement$: modalTransition.stores.element$, + ...stateProps, + }), + directives: { + modalPortalDirective, + backdropPortalDirective, + backdropDirective: bindDirectiveNoArg(backdropTransition.directives.directive), + modalDirective: mergeDirectives(bindDirectiveNoArg(modalTransition.directives.directive), sliblingsInert, directiveSubscribe(action$)), + }, + patch, + api: { + close, + async open() { + patch({visible: true}); + await promiseFromStore(hidden$).promise; + return hideResult; + }, + patch, + }, + actions: { + modalClick(event) { + if (event.currentTarget === event.target && closeOnOutsideClick$()) { + close(modalOutsideClick); + } + }, + closeButtonClick(event) { + close(modalCloseButtonClick); + }, + }, + }; + + return res; +}; diff --git a/core/lib/modal/scrollbars.spec.ts b/core/lib/modal/scrollbars.spec.ts new file mode 100644 index 0000000000..0d37548397 --- /dev/null +++ b/core/lib/modal/scrollbars.spec.ts @@ -0,0 +1,48 @@ +import {describe, expect, test} from 'vitest'; +import {removeScrollbars, revertScrollbars} from './scrollbars'; + +describe('scrollbars', () => { + test('basic feature, restore body overflow to scroll', () => { + document.body.style.overflow = 'scroll'; + removeScrollbars(); + expect(document.body.style.overflow).toBe('hidden'); + revertScrollbars(); + expect(document.body.style.overflow).toBe('scroll'); + }); + + test('basic feature, restore body overflow to auto', () => { + document.body.style.overflow = 'auto'; + removeScrollbars(); + expect(document.body.style.overflow).toBe('hidden'); + revertScrollbars(); + expect(document.body.style.overflow).toBe('auto'); + }); + + test('calling removeScrollbars twice', () => { + document.body.style.overflow = 'scroll'; + removeScrollbars(); + removeScrollbars(); + expect(document.body.style.overflow).toBe('hidden'); + revertScrollbars(); + expect(document.body.style.overflow).toBe('scroll'); + }); + + test('check paddingRight', () => { + const initialPaddingRight = document.body.style.paddingRight; + const initialClientWidth = (document.documentElement as any).clientWidth; + try { + document.body.style.paddingRight = '10px'; + (document.documentElement as any).clientWidth = window.innerWidth - 32; // simulation of a 32px scrollbar in happy-dom + expect(document.body.style.paddingRight).toBe('10px'); + removeScrollbars(); + expect(document.body.style.overflow).toBe('hidden'); + expect(document.body.style.paddingRight).toBe('42px'); // 10+32 + revertScrollbars(); + expect(document.body.style.overflow).toBe('scroll'); + expect(document.body.style.paddingRight).toBe('10px'); + } finally { + document.body.style.paddingRight = initialPaddingRight; + (document.documentElement as any).clientWidth = initialClientWidth; + } + }); +}); diff --git a/core/lib/modal/scrollbars.ts b/core/lib/modal/scrollbars.ts new file mode 100644 index 0000000000..fee4f6affd --- /dev/null +++ b/core/lib/modal/scrollbars.ts @@ -0,0 +1,31 @@ +import {noop} from '../utils'; + +const internalRemoveScrollbars = () => { + const scrollbarWidth = Math.abs(window.innerWidth - document.documentElement.clientWidth); + const body = document.body; + const bodyStyle = body.style; + const {overflow, paddingRight} = bodyStyle; + if (scrollbarWidth > 0) { + const actualPadding = parseFloat(window.getComputedStyle(body).paddingRight); + bodyStyle.paddingRight = `${actualPadding + scrollbarWidth}px`; + } + bodyStyle.overflow = 'hidden'; + return () => { + if (scrollbarWidth > 0) { + bodyStyle.paddingRight = paddingRight; + } + bodyStyle.overflow = overflow; + }; +}; + +let internalRevert = noop; + +export const removeScrollbars = () => { + internalRevert(); + internalRevert = internalRemoveScrollbars(); +}; + +export const revertScrollbars = () => { + internalRevert(); + internalRevert = noop; +}; diff --git a/core/lib/pagination.spec.ts b/core/lib/pagination.spec.ts new file mode 100644 index 0000000000..d83b8fd96d --- /dev/null +++ b/core/lib/pagination.spec.ts @@ -0,0 +1,378 @@ +import type {SpyInstance} from 'vitest'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; +import type {PaginationState, PaginationWidget} from './pagination'; +import {createPagination} from './pagination'; +import {ngBootstrapPagination} from './pagination.utils'; + +describe(`Pagination`, () => { + let pagination: PaginationWidget; + let state: PaginationState; + + let consoleErrorSpy: SpyInstance, ReturnType>; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + pagination = createPagination(); + // We test here the pagination utils algo... + // Need to move those test to only ngBootstrapPagination tests + // Need to add test of the simple widget alone. + pagination.patch({pagesFactory: ngBootstrapPagination(0, false, true)}); + const unsubscribe = pagination.state$.subscribe((newState) => { + state = newState; + }); + return () => { + unsubscribe(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }; + }); + + const expectLogInvalidValue = () => { + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toContain('invalid'); + consoleErrorSpy.mockClear(); + }; + + test(`should have sensible state`, () => { + // TODO we don't test ariaPageLabel here... + expect(state).toMatchObject({ + pageCount: 1, // total number of page + page: 1, // current page + pages: [1], // list of the visible pages + previousDisabled: true, + nextDisabled: true, + disabled: false, + directionLinks: true, + boundaryLinks: false, + size: null, + activeLabel: '(current)', + ariaFirstLabel: 'Action link for first page', + ariaLastLabel: 'Action link for last page', + ariaNextLabel: 'Action link for next page', + ariaPreviousLabel: 'Action link for previous page', + }); + }); + + test('should calculate and update no of pages (default page size)', () => { + expect(state).toContain({page: 1, pageCount: 1}); + + pagination.patch({collectionSize: 100}); + expect(state).toContain({page: 1, pageCount: 10}); + + pagination.patch({collectionSize: 200}); + expect(state).toContain({page: 1, pageCount: 20}); + }); + + test('should calculate and update no of pages (custom page size)', () => { + expect(state).toContain({page: 1, pageCount: 1}); + + pagination.patch({collectionSize: 100, pageSize: 20}); + expect(state).toContain({page: 1, pageCount: 5}); + + pagination.patch({collectionSize: 200}); + expect(state).toContain({page: 1, pageCount: 10}); + + pagination.patch({pageSize: 10}); + expect(state).toContain({page: 1, pageCount: 20}); + }); + + test('should allow setting a page within a valid range (default page size)', () => { + pagination.patch({collectionSize: 100, page: 2}); + expect(state).toContain({page: 2, pageCount: 10}); + }); + + test(`should set page within [1, pageCount]`, () => { + expect(state).toContain({page: 1, pageCount: 1}); + + pagination.patch({page: 5}); + expect(state).toContain({page: 1, pageCount: 1}); + + pagination.patch({collectionSize: 60}); + expect(state).toContain({page: 1, pageCount: 6}); + + pagination.patch({page: 5}); + expect(state).toContain({page: 5, pageCount: 6}); + + pagination.patch({page: 0}); + expect(state).toContain({page: 1, pageCount: 6}); + + pagination.patch({page: 10}); + expect(state).toContain({page: 6, pageCount: 6}); + + pagination.patch({page: -1}); + expect(state).toContain({page: 1, pageCount: 6}); + + pagination.patch({page: 5, collectionSize: 40}); + expect(state).toContain({page: 4, pageCount: 4}); + }); + + test(`should nextDisabled react correctly`, () => { + expect(state).toContain({page: 1, pageCount: 1, nextDisabled: true}); + + pagination.patch({collectionSize: 60}); + expect(state).toContain({page: 1, pageCount: 6, nextDisabled: false}); + + pagination.patch({page: 6}); + expect(state).toContain({page: 6, pageCount: 6, nextDisabled: true}); + + pagination.patch({page: 0}); + expect(state).toContain({page: 1, pageCount: 6, nextDisabled: false}); + + pagination.patch({page: 10}); + expect(state).toContain({page: 6, pageCount: 6, nextDisabled: true}); + }); + + test(`should previousDisabled react correctly`, () => { + expect(state).toContain({page: 1, previousDisabled: true}); + + pagination.patch({collectionSize: 60}); + expect(state).toContain({page: 1, previousDisabled: true}); + + pagination.patch({page: 6}); + expect(state).toContain({page: 6, previousDisabled: false}); + + pagination.patch({page: 0}); + expect(state).toContain({page: 1, previousDisabled: true}); + + pagination.patch({page: 10}); + expect(state).toContain({page: 6, previousDisabled: false}); + + pagination.patch({collectionSize: 10}); + expect(state).toContain({page: 1, previousDisabled: true}); + }); + + test(`should pages changes according to collectionSize`, () => { + expect(state.pages).toStrictEqual([1]); + + pagination.patch({collectionSize: 60}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6]); + + pagination.patch({collectionSize: 45}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5]); + }); + + test(`should pages changes according to pageSize`, () => { + expect(state.pages).toStrictEqual([1]); + + pagination.patch({pageSize: 5, collectionSize: 10}); + expect(state.pages).toStrictEqual([1, 2]); + + pagination.patch({collectionSize: 60, pageSize: 20}); + expect(state.pages).toStrictEqual([1, 2, 3]); + + pagination.patch({pageSize: 30}); + expect(state.pages).toStrictEqual([1, 2]); + }); + + test(`should pages changes according to maxSize`, () => { + expect(state.pages).toStrictEqual([1]); + + pagination.patch({pagesFactory: ngBootstrapPagination(0, false, false)}); + pagination.patch({pageSize: 10, collectionSize: 70}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + + pagination.patch({pagesFactory: ngBootstrapPagination(3, false, false)}); + expect(state.pages).toStrictEqual([1, 2, 3]); + + pagination.patch({page: 3}); + expect(state.pages).toStrictEqual([1, 2, 3]); + expect(state).toContain({page: 3, nextDisabled: false}); + + pagination.patch({page: 4}); + expect(state.pages).toStrictEqual([4, 5, 6]); + expect(state).toContain({page: 4, previousDisabled: false}); + + pagination.patch({page: 7}); + expect(state.pages).toStrictEqual([7]); + expect(state).toContain({page: 7, nextDisabled: true}); + + pagination.patch({pagesFactory: ngBootstrapPagination(100, false, false)}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + expect(state).toContain({page: 7, nextDisabled: true}); + }); + + test('should rotate pages correctly', () => { + pagination.patch({pageSize: 10, collectionSize: 70}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + expect(state).toContain({page: 1, previousDisabled: true}); + + pagination.patch({pagesFactory: ngBootstrapPagination(3, true, false)}); + expect(state.pages).toStrictEqual([1, 2, 3]); + expect(state).toContain({page: 1, previousDisabled: true}); + + pagination.patch({page: 2}); + expect(state.pages).toStrictEqual([1, 2, 3]); + expect(state).toContain({page: 2, previousDisabled: false}); + + pagination.patch({page: 3}); + expect(state.pages).toStrictEqual([2, 3, 4]); + expect(state).toContain({page: 3, previousDisabled: false}); + + pagination.patch({page: 7}); + expect(state.pages).toStrictEqual([5, 6, 7]); + expect(state).toContain({page: 7, nextDisabled: true}); + + pagination.patch({pagesFactory: ngBootstrapPagination(4, true, false)}); + expect(state.pages).toStrictEqual([4, 5, 6, 7]); + expect(state).toContain({page: 7, nextDisabled: true}); + + pagination.patch({page: 5}); + expect(state.pages).toStrictEqual([3, 4, 5, 6]); + expect(state).toContain({page: 5, nextDisabled: false}); + + pagination.patch({page: 3}); + expect(state.pages).toStrictEqual([1, 2, 3, 4]); + expect(state).toContain({page: 3, nextDisabled: false}); + }); + + test('should display ellipsis correctly', () => { + pagination.patch({pageSize: 10, collectionSize: 70}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + expect(state).toContain({page: 1, previousDisabled: true}); + + pagination.patch({pagesFactory: ngBootstrapPagination(3, false, true)}); + expect(state.pages).toStrictEqual([1, 2, 3, -1, 7]); + expect(state).toContain({page: 1, previousDisabled: true}); + + pagination.patch({page: 4}); + expect(state.pages).toStrictEqual([1, -1, 4, 5, 6, 7]); + expect(state).toContain({page: 4, previousDisabled: false}); + + pagination.patch({page: 7}); + expect(state.pages).toStrictEqual([1, -1, 7]); + expect(state).toContain({page: 7, nextDisabled: true}); + + pagination.patch({pagesFactory: ngBootstrapPagination(3, true, true)}); + pagination.patch({page: 1}); + expect(state.pages).toStrictEqual([1, 2, 3, -1, 7]); + expect(state).toContain({page: 1, previousDisabled: true}); + + pagination.patch({page: 2}); + expect(state.pages).toStrictEqual([1, 2, 3, -1, 7]); + expect(state).toContain({page: 2, previousDisabled: false}); + + pagination.patch({page: 3}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, -1, 7]); + expect(state).toContain({page: 3, previousDisabled: false}); + + pagination.patch({page: 4}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + expect(state).toContain({page: 4, previousDisabled: false}); + + pagination.patch({page: 5}); + expect(state.pages).toStrictEqual([1, -1, 4, 5, 6, 7]); + expect(state).toContain({page: 5, previousDisabled: false}); + + pagination.patch({page: 6}); + expect(state.pages).toStrictEqual([1, -1, 5, 6, 7]); + expect(state).toContain({page: 6, previousDisabled: false}); + + pagination.patch({page: 7}); + expect(state.pages).toStrictEqual([1, -1, 5, 6, 7]); + expect(state).toContain({page: 7, nextDisabled: true}); + + pagination.patch({pagesFactory: ngBootstrapPagination(100, true, true)}); + pagination.patch({page: 5}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + expect(state).toContain({page: 5, nextDisabled: false}); + }); + + test('should use page number instead of ellipsis when ellipsis hides a single page', () => { + pagination.patch({pagesFactory: ngBootstrapPagination(5, true, true)}); + pagination.patch({pageSize: 10, collectionSize: 120}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, -1, 12]); + + pagination.patch({page: 2}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, -1, 12]); + + pagination.patch({page: 3}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, -1, 12]); + + pagination.patch({page: 4}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, -1, 12]); + + pagination.patch({page: 5}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7, -1, 12]); + + pagination.patch({page: 6}); + expect(state.pages).toStrictEqual([1, -1, 4, 5, 6, 7, 8, -1, 12]); + + pagination.patch({page: 7}); + expect(state.pages).toStrictEqual([1, -1, 5, 6, 7, 8, 9, -1, 12]); + + pagination.patch({page: 8}); + expect(state.pages).toStrictEqual([1, -1, 6, 7, 8, 9, 10, 11, 12]); + + pagination.patch({page: 9}); + expect(state.pages).toStrictEqual([1, -1, 7, 8, 9, 10, 11, 12]); + + pagination.patch({page: 10}); + expect(state.pages).toStrictEqual([1, -1, 8, 9, 10, 11, 12]); + + pagination.patch({page: 11}); + expect(state.pages).toStrictEqual([1, -1, 8, 9, 10, 11, 12]); + + pagination.patch({page: 12}); + expect(state.pages).toStrictEqual([1, -1, 8, 9, 10, 11, 12]); + }); + + test('should handle edge "collectionSize" values', () => { + pagination.patch({collectionSize: 70}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + + pagination.patch({collectionSize: 0}); + expect(state.pages).toStrictEqual([1]); + + pagination.patch({collectionSize: NaN}); + expect(state.pages).toStrictEqual([1]); + expectLogInvalidValue(); + }); + + test('should handle edge "pageSize" values', () => { + pagination.patch({collectionSize: 70}); + expect(state.pages).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + + pagination.patch({collectionSize: 0}); + expect(state.pages).toStrictEqual([1]); + + pagination.patch({collectionSize: NaN}); + expect(state.pages).toStrictEqual([1]); + expectLogInvalidValue(); + }); + + test('should handle edge "page" values', () => { + pagination.patch({page: 0, collectionSize: 20}); + expect(state.pages).toStrictEqual([1, 2]); + expect(state).toContain({page: 1}); + + pagination.patch({page: 2022}); + expect(state.pages).toStrictEqual([1, 2]); + expect(state).toContain({page: 2}); + + pagination.patch({page: NaN}); + expect(state.pages).toStrictEqual([1, 2]); + expect(state).toContain({page: 2}); + expectLogInvalidValue(); + }); + + test('should onChange be called correctly', () => { + function onPageChangeCustom(page: number) {} + const mock = vi.fn().mockImplementation(onPageChangeCustom); + pagination.patch({onPageChange: mock}); + pagination.patch({collectionSize: 70, page: 2}); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(2); + mock.mockRestore(); + pagination.patch({collectionSize: 150, page: 2}); + expect(mock).not.toHaveBeenCalled(); + pagination.patch({collectionSize: 1}); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(1); + mock.mockRestore(); + pagination.patch({collectionSize: 1, page: 10}); + // value was adjusted, onPageChange has to be called even though the new value + // is the same as the previous call to onPageChange + expect(mock).toHaveBeenCalledWith(1); + mock.mockRestore(); + }); +}); diff --git a/core/lib/pagination.ts b/core/lib/pagination.ts new file mode 100644 index 0000000000..96ac88036a --- /dev/null +++ b/core/lib/pagination.ts @@ -0,0 +1,443 @@ +import {computed} from '@amadeus-it-group/tansu'; +import type {ConfigValidator, PropsConfig} from './services'; +import {bindableDerived, INVALID_VALUE, stateStores, writablesForProps} from './services'; +import {getValueInRange, isNumber} from './services/checks'; +import {typeBoolean, typeFunction, typeNumber, typeString} from './services/writables'; +import type {Widget, SlotContent, WidgetSlotContext} from './types'; +import {noop} from './utils'; + +/** + * A type for the slot context of the pagination widget + */ +export type PaginationContext = WidgetSlotContext; + +/** + * A type for the slot context of the pagination widget when the slot is the number label + */ +export interface PaginationNumberContext extends PaginationContext { + /** + * Displayed page + */ + displayedPage: number; +} + +export interface PaginationCommonPropsAndState { + /** + * The current page. + * + * Page numbers start with `1`. + * @defaultValue 1 + */ + page: number; // value of the current/init page to display + + /** + * The pagination display size. + * + * Bootstrap currently supports small and large sizes. + * @defaultValue null + */ + size: 'sm' | 'lg' | null; + + /** + * The label for the "active" page. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue '(current)' + */ + activeLabel: string; + + /** + * The label for the "First" page button. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue 'Action link for first page' + */ + ariaFirstLabel: string; + + /** + * The label for the "Previous" page button. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue 'Action link for previous page' + */ + ariaPreviousLabel: string; + + /** + * The label for the "Next" page button. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue 'Action link for next page' + */ + ariaNextLabel: string; + + /** + * The label for the "Last" page button. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue 'Action link for last page' + */ + ariaLastLabel: string; + + /** + * If `true`, pagination links will be disabled. + * @defaultValue false + */ + disabled: boolean; + + /** + * If `true`, the "Next" and "Previous" page links are shown. + * @defaultValue true + */ + directionLinks: boolean; + + /** + * If `true`, the "First" and "Last" page links are shown. + * @defaultValue false + */ + boundaryLinks: boolean; + + /** + * An input to add a custom class to the UL + * @defaultValue '' + */ + className: string; + + /** + * The template to use for the ellipsis slot + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue '…' + */ + slotEllipsis: SlotContent; + + /** + * The template to use for the first slot + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue '«' + */ + slotFirst: SlotContent; + + /** + * The template to use for the previous slot + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue '‹' + */ + slotPrevious: SlotContent; + + /** + * The template to use for the next slot + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue '›' + */ + slotNext: SlotContent; + + /** + * The template to use for the last slot + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @defaultValue '»' + */ + slotLast: SlotContent; + + /** + * The template to use for the pages slot + * To use to customize the pages view + * override any configuration parameters provided for this + */ + slotPages: SlotContent; + + /** + * The template to use for the number slot + * override any configuration parameters provided for this + * for I18n, we suggest to use the global configuration + * @defaultValue + * ```ts + * ({displayedPage}) => `${displayedPage}` + * ``` + * @param displayedPage - The current page number + */ + slotNumberLabel: SlotContent; +} + +export interface PaginationProps extends PaginationCommonPropsAndState { + /** + * The number of items in your paginated collection. + * + * Note, that this is not the number of pages. Page numbers are calculated dynamically based on + * `collectionSize` and `pageSize`. + * + * Ex. if you have 100 items in your collection and displaying 20 items per page, you'll end up with 5 pages. + * + * Whatever the collectionSize the page number is of minimum 1. + * @defaultValue 0 + */ + collectionSize: number; + + /** + * The number of items per page. + * @defaultValue 10 + * @remarks min value is 1 + */ + pageSize: number; + + /** + * An event fired when the page is changed. + * + * Event payload is the number of the newly selected page. + * + * Page numbers start with `1`. + * @defaultValue + * ```ts + * () => {} + * ``` + */ + onPageChange: (page: number) => void; + + /** + * pagesFactory returns a function computing the array of pages to be displayed + * as number (-1 are treated as ellipsis). + * Use Page slot to customize the pages view and not this + * @param page - The current page number + * @param pageCount - The total number of pages + * @defaultValue + * ```ts + * ({page, pageCount}) => { + * const pages: number[] = []; + * for (let i = 1; i <= pageCount; i++) { + * pages.push(i); + * } + * return pages; + * } + * ``` + */ + pagesFactory: (page: number, pageCount: number) => number[]; + + /** + * Provide the label for each "Page" page button. + * This is used for accessibility purposes. + * for I18n, we suggest to use the global configuration + * override any configuration parameters provided for this + * @param processPage - The current page number + * @param pageCount - The total number of pages + * @defaultValue + * ```ts + * ({processPage, pageCount}) => `Page ${processPage} of ${pageCount}` + * ``` + */ + ariaPageLabel: (processPage: number, pageCount: number) => string; +} + +export interface PaginationState extends PaginationCommonPropsAndState { + /** + * The number of pages. + */ + pageCount: number; + /** + * The current pages, the number in the Array is the number of the page. + */ + pages: number[]; + /** + * true if the previous link need to be disabled + */ + previousDisabled: boolean; + /** + * true if the next link need to be disabled + */ + nextDisabled: boolean; + /** + * The label for each "Page" page link. + */ + pagesLabel: string[]; +} + +export interface PaginationActions { + /** + * To "go" to a specific page + * @param page - The page number to select + */ + select(page: number): void; + /** + * To "go" to the first page + */ + first(): void; + /** + * To "go" to the previous page + */ + previous(): void; + /** + * To "go" to the next page + */ + next(): void; + /** + * To "go" to the last page + */ + last(): void; +} + +export interface PaginationApi { + // FIXME: should this be in actions too (even though it is not an action, but it is mostly only useful from slots)?? + /** + * If the page number is -1 return true + * @param page - The page number to check + */ + isEllipsis(page: number): boolean; +} + +export type PaginationWidget = Widget; + +const defaultConfig: PaginationProps = { + page: 1, + collectionSize: 0, + pageSize: 10, + disabled: false, + directionLinks: true, + boundaryLinks: false, + size: null, + onPageChange: noop, + pagesFactory: (page: number, pageCount: number) => { + // TODO extract this for testing + const pages: number[] = []; + for (let i = 1; i <= pageCount; i++) { + pages.push(i); + } + return pages; + }, + activeLabel: '(current)', + ariaPageLabel: (processPage: number, pageCount: number) => `Page ${processPage} of ${pageCount}`, + ariaFirstLabel: 'Action link for first page', + ariaPreviousLabel: 'Action link for previous page', + ariaNextLabel: 'Action link for next page', + ariaLastLabel: 'Action link for last page', + className: '', + slotEllipsis: '…', + slotFirst: '«', + slotPrevious: '‹', + slotNext: '›', + slotLast: '»', + slotPages: undefined, + slotNumberLabel: ({displayedPage}) => `${displayedPage}`, +}; + +export function getPaginationDefaultConfig() { + return {...defaultConfig}; +} + +const configValidator: ConfigValidator = { + page: typeNumber, + collectionSize: typeNumber, + pageSize: typeNumber, + disabled: typeBoolean, + directionLinks: typeBoolean, + boundaryLinks: typeBoolean, + size: {normalizeValue: (value) => (value === 'lg' || value === 'sm' || value === null ? value : INVALID_VALUE)}, + onPageChange: typeFunction, + pagesFactory: typeFunction, + activeLabel: typeString, + ariaPageLabel: typeFunction, + ariaFirstLabel: typeString, + ariaPreviousLabel: typeString, + ariaNextLabel: typeString, + ariaLastLabel: typeString, + className: typeString, +}; + +export function createPagination(config$?: PropsConfig): PaginationWidget { + const [ + { + // dirty inputs that need adjustment: + page$: _dirtyPage$, + // clean inputs with value validation: + collectionSize$, + pageSize$, + onPageChange$, + pagesFactory$, + ariaPageLabel$, + ...stateProps + }, + patch, + ] = writablesForProps(defaultConfig, config$, configValidator); + + // computed + // nb total of Pages. + const pageCount$ = computed(() => { + let pageCount = Math.ceil(collectionSize$() / pageSize$()); + // Here we choose to always display a page when collection size is 0 + if (!isNumber(pageCount) || pageCount < 1) { + pageCount = 1; + } + return pageCount; + }); + // current page + const page$ = bindableDerived(onPageChange$, [_dirtyPage$, pageCount$], ([dirtyPage, pageCount]) => getValueInRange(dirtyPage, pageCount, 1)); + + const pages$ = computed(() => pagesFactory$()(page$(), pageCount$())); + + const nextDisabled$ = computed(() => page$() === pageCount$() || stateProps.disabled$()); + const previousDisabled$ = computed(() => page$() === 1 || stateProps.disabled$()); + + const pagesLabel$ = computed(() => { + const ariaPageLabel = ariaPageLabel$(); + const pageCount = pageCount$(); + return pages$().map((page) => ariaPageLabel(page, pageCount)); + }); + + return { + ...stateStores({ + pageCount$, + page$, + pages$, + nextDisabled$, + previousDisabled$, + pagesLabel$, + ...stateProps, + }), + patch, + actions: { + /** + * Set the current page pageNumber (starting from 1) + * @param pageNumber - Current page number to set. + * Value is normalized between 1 and the number of page + */ + select(pageNumber: number) { + patch({page: pageNumber}); + }, + + /** + * Select the first page + */ + first() { + patch({page: 1}); + }, + + /** + * Select the previous page + */ + previous() { + patch({page: page$() - 1}); + }, + + /** + * Select the next page + */ + next() { + patch({page: page$() + 1}); + }, + + /** + * Select the last page + */ + last() { + patch({page: pageCount$()}); + }, + }, + api: { + isEllipsis(pageNumber: number): boolean { + return pageNumber === -1; + }, + }, + directives: {}, + }; +} diff --git a/core/lib/pagination.utils.spec.ts b/core/lib/pagination.utils.spec.ts new file mode 100644 index 0000000000..807e67362e --- /dev/null +++ b/core/lib/pagination.utils.spec.ts @@ -0,0 +1,84 @@ +import type {SpyInstance} from 'vitest'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; +import {ngBootstrapPagination} from './pagination.utils'; + +describe(`Pagination utils`, () => { + let pageFactory: (page: number, pageCount: number) => number[]; + let consoleErrorSpy: SpyInstance, ReturnType>; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // We test here the pagination utils algo... + pageFactory = ngBootstrapPagination(0, false, true); + return () => { + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }; + }); + + test(`should pages changes according to pageCount`, () => { + expect(pageFactory(1, 1)).toStrictEqual([1]); + expect(pageFactory(1, 10)).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(pageFactory(1, 20)).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]); + }); + + test(`should pages changes according to maxSize`, () => { + pageFactory = ngBootstrapPagination(0, false, false); + expect(pageFactory(1, 7)).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + pageFactory = ngBootstrapPagination(3, false, false); + expect(pageFactory(1, 7)).toStrictEqual([1, 2, 3]); + expect(pageFactory(3, 7)).toStrictEqual([1, 2, 3]); + expect(pageFactory(4, 7)).toStrictEqual([4, 5, 6]); + expect(pageFactory(7, 7)).toStrictEqual([7]); + pageFactory = ngBootstrapPagination(100, false, false); + expect(pageFactory(7, 7)).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + test(`should rotate pages correctly`, () => { + pageFactory = ngBootstrapPagination(3, true, false); + expect(pageFactory(1, 7)).toStrictEqual([1, 2, 3]); + expect(pageFactory(2, 7)).toStrictEqual([1, 2, 3]); + expect(pageFactory(3, 7)).toStrictEqual([2, 3, 4]); + expect(pageFactory(7, 7)).toStrictEqual([5, 6, 7]); + pageFactory = ngBootstrapPagination(4, true, false); + expect(pageFactory(7, 7)).toStrictEqual([4, 5, 6, 7]); + expect(pageFactory(5, 7)).toStrictEqual([3, 4, 5, 6]); + expect(pageFactory(3, 7)).toStrictEqual([1, 2, 3, 4]); + }); + + test(`should display ellipsis correctly`, () => { + pageFactory = ngBootstrapPagination(3, false, true); + expect(pageFactory(1, 7)).toStrictEqual([1, 2, 3, -1, 7]); + expect(pageFactory(4, 7)).toStrictEqual([1, -1, 4, 5, 6, 7]); + expect(pageFactory(7, 7)).toStrictEqual([1, -1, 7]); + pageFactory = ngBootstrapPagination(3, true, true); + expect(pageFactory(7, 7)).toStrictEqual([1, -1, 5, 6, 7]); + expect(pageFactory(1, 7)).toStrictEqual([1, 2, 3, -1, 7]); + expect(pageFactory(2, 7)).toStrictEqual([1, 2, 3, -1, 7]); + expect(pageFactory(3, 7)).toStrictEqual([1, 2, 3, 4, -1, 7]); + expect(pageFactory(4, 7)).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + expect(pageFactory(5, 7)).toStrictEqual([1, -1, 4, 5, 6, 7]); + expect(pageFactory(6, 7)).toStrictEqual([1, -1, 5, 6, 7]); + expect(pageFactory(7, 7)).toStrictEqual([1, -1, 5, 6, 7]); + pageFactory = ngBootstrapPagination(100, true, true); + expect(pageFactory(7, 7)).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + expect(pageFactory(5, 7)).toStrictEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + test(`should use page number instead of ellipsis when ellipsis hides a single page`, () => { + pageFactory = ngBootstrapPagination(5, true, true); + expect(pageFactory(1, 1)).toStrictEqual([1]); + expect(pageFactory(1, 12)).toStrictEqual([1, 2, 3, 4, 5, -1, 12]); + expect(pageFactory(2, 12)).toStrictEqual([1, 2, 3, 4, 5, -1, 12]); + expect(pageFactory(3, 12)).toStrictEqual([1, 2, 3, 4, 5, -1, 12]); + expect(pageFactory(4, 12)).toStrictEqual([1, 2, 3, 4, 5, 6, -1, 12]); + expect(pageFactory(5, 12)).toStrictEqual([1, 2, 3, 4, 5, 6, 7, -1, 12]); + expect(pageFactory(6, 12)).toStrictEqual([1, -1, 4, 5, 6, 7, 8, -1, 12]); + expect(pageFactory(7, 12)).toStrictEqual([1, -1, 5, 6, 7, 8, 9, -1, 12]); + expect(pageFactory(8, 12)).toStrictEqual([1, -1, 6, 7, 8, 9, 10, 11, 12]); + expect(pageFactory(9, 12)).toStrictEqual([1, -1, 7, 8, 9, 10, 11, 12]); + expect(pageFactory(10, 12)).toStrictEqual([1, -1, 8, 9, 10, 11, 12]); + expect(pageFactory(11, 12)).toStrictEqual([1, -1, 8, 9, 10, 11, 12]); + expect(pageFactory(12, 12)).toStrictEqual([1, -1, 8, 9, 10, 11, 12]); + }); +}); diff --git a/core/lib/pagination.utils.ts b/core/lib/pagination.utils.ts new file mode 100644 index 0000000000..512fff1bcd --- /dev/null +++ b/core/lib/pagination.utils.ts @@ -0,0 +1,97 @@ +// Utility functions +function _applyPagination(page: number, maxSize: number): [number, number] { + const pp = Math.ceil(page / maxSize) - 1; + const start = pp * maxSize; + // console.log('start', start, 'pp', pp, 'page', page, 'maxSize', maxSize); + const end = start + maxSize; + + return [start, end]; +} + +/** + * Appends ellipses and first/last page number to the displayed pages + */ +function _applyEllipses(start: number, end: number, ellipses: boolean, pages: number[], pageCount: number) { + if (ellipses) { + if (start > 0) { + // The first page will always be included. If the displayed range + // starts after the third page, then add ellipsis. But if the range + // starts on the third page, then add the second page instead of + // an ellipsis, because the ellipsis would only hide a single page. + if (start > 2) { + pages.unshift(-1); + } else if (start === 2) { + pages.unshift(2); + } + pages.unshift(1); + } + if (end < pageCount) { + // The last page will always be included. If the displayed range + // ends before the third-last page, then add ellipsis. But if the range + // ends on third-last page, then add the second-last page instead of + // an ellipsis, because the ellipsis would only hide a single page. + if (end < pageCount - 2) { + pages.push(-1); + } else if (end === pageCount - 2) { + pages.push(pageCount - 1); + } + pages.push(pageCount); + } + } +} + +/** + * Rotates page numbers based on maxSize items visible. + * Currently selected page stays in the middle: + * + * Ex. for selected page = 6: + * [5,*6*,7] for maxSize = 3 + * [4,5,*6*,7] for maxSize = 4 + */ +function _applyRotation(page: number, maxSize: number, pageCount: number): [number, number] { + let start = 0; + let end = pageCount; + const leftOffset = Math.floor(maxSize / 2); + const rightOffset = maxSize % 2 === 0 ? leftOffset - 1 : leftOffset; + + if (page <= leftOffset) { + // very beginning, no rotation -> [0..maxSize] + end = maxSize; + } else if (pageCount - page < leftOffset) { + // very end, no rotation -> [len-maxSize..len] + start = pageCount - maxSize; + } else { + // rotate + start = page - leftOffset - 1; + end = page + rightOffset; + } + + return [start, end]; +} + +export function ngBootstrapPagination(maxSize: number, rotate: boolean, ellipses: boolean): (page: number, pageCount: number) => number[] { + return function (page: number, pageCount: number) { + let pages: number[] = []; + for (let i = 1; i <= pageCount; i++) { + pages.push(i); + } + // apply maxSize if necessary + if (maxSize > 0 && pageCount > maxSize) { + let start = 0; + let end = pageCount; + + // either paginating or rotating page numbers + if (rotate) { + [start, end] = _applyRotation(page, maxSize, pageCount); + } else { + [start, end] = _applyPagination(page, maxSize); + } + + pages = pages.slice(start, end); + + // adding ellipses + _applyEllipses(start, end, ellipses, pages, pageCount); + } + return pages; + }; +} diff --git a/core/lib/rating.spec.ts b/core/lib/rating.spec.ts new file mode 100644 index 0000000000..12489808a8 --- /dev/null +++ b/core/lib/rating.spec.ts @@ -0,0 +1,646 @@ +import type {UnsubscribeFunction, WritableSignal} from '@amadeus-it-group/tansu'; +import {computed, writable} from '@amadeus-it-group/tansu'; +import type {SpyInstance} from 'vitest'; +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; +import type {RatingWidget, RatingProps} from './rating'; +import {createRating} from './rating'; +import type {WidgetState} from './types'; + +function keyboardEvent(key: string): KeyboardEvent { + return { + key, + preventDefault() {}, + stopPropagation() {}, + } as KeyboardEvent; +} + +describe(`Rating`, () => { + describe('with subscription on the state', () => { + let rating: RatingWidget; + let state: WidgetState; + let unsubscribe: UnsubscribeFunction; + let stateChangeCount = 0; + const hovers: number[] = []; + const leaves: number[] = []; + + const callbacks = { + onHover: (i: number) => hovers.push(i), + onLeave: (i: number) => leaves.push(i), + }; + + let defConfig: WritableSignal>; + let consoleErrorSpy: SpyInstance, ReturnType>; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + defConfig = writable({}); + rating = createRating(computed(() => ({...callbacks, ...defConfig()}))); + unsubscribe = rating.state$.subscribe((newState) => { + stateChangeCount++; + state = newState; + }); + }); + + afterEach(() => { + stateChangeCount = 0; + hovers.length = 0; + leaves.length = 0; + unsubscribe(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + const expectLogInvalidValue = () => { + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0][0]).toContain('invalid'); + consoleErrorSpy.mockClear(); + }; + + test(`should have sensible state`, () => { + expect(state).toStrictEqual({ + rating: 0, + ariaLabel: 'Rating', + ariaLabelledBy: '', + ariaValueText: `0 out of 10`, + maxRating: 10, + visibleRating: 0, + disabled: false, + readonly: false, + resettable: true, + tabindex: 0, + isInteractive: true, + stars: Array.from({length: 10}, (_, i) => ({fill: 0, index: i})), + className: '', + slotStar: expect.any(Function), + }); + }); + + test(`should ignore invalid 'rating' values`, () => { + expect(state).toContain({rating: 0, maxRating: 10}); + expect(stateChangeCount).toBe(1); + + // note that this is not invalid, it only goes back to the default value + rating.patch({rating: undefined as any}); + expect(state).toContain({rating: 0}); + expect(stateChangeCount).toBe(1); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + rating.patch({rating: 'blah' as any}); + expect(state).toContain({rating: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({rating: Infinity as any}); + expect(state).toContain({rating: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({rating: NaN as any}); + expect(state).toContain({rating: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({rating: {} as any}); + expect(state).toContain({rating: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + }); + + test(`should ignore invalid 'maxRating' values`, () => { + expect(state).toContain({rating: 0, maxRating: 10}); + expect(stateChangeCount).toBe(1); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + rating.patch({maxRating: null as any}); + expect(state).toContain({maxRating: 10}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + // note that this is not invalid, it only goes back to the default value + rating.patch({maxRating: undefined as any}); + expect(state).toContain({maxRating: 10}); + expect(stateChangeCount).toBe(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + rating.patch({maxRating: 'blah' as any}); + expect(state).toContain({maxRating: 10}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({maxRating: Infinity as any}); + expect(state).toContain({maxRating: 10}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({maxRating: NaN as any}); + expect(state).toContain({maxRating: 10}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({maxRating: {} as any}); + expect(state).toContain({maxRating: 10}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + }); + + test(`should ignore invalid 'disabled' values`, () => { + expect(state).toContain({disabled: false}); + expect(stateChangeCount).toBe(1); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + rating.patch({disabled: null as any}); + expect(state).toContain({disabled: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + // note that this is not invalid, it only goes back to the default value + rating.patch({disabled: undefined as any}); + expect(state).toContain({disabled: false}); + expect(stateChangeCount).toBe(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + rating.patch({disabled: 'blah' as any}); + expect(state).toContain({disabled: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({disabled: Infinity as any}); + expect(state).toContain({disabled: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({disabled: NaN as any}); + expect(state).toContain({disabled: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({disabled: {} as any}); + expect(state).toContain({disabled: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + }); + + test(`should ignore invalid 'readonly' values`, () => { + expect(state).toContain({readonly: false}); + expect(stateChangeCount).toBe(1); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + rating.patch({readonly: null as any}); + expect(state).toContain({readonly: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + // note that this is not invalid, it only goes back to the default value + rating.patch({readonly: undefined as any}); + expect(state).toContain({readonly: false}); + expect(stateChangeCount).toBe(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + rating.patch({readonly: 'blah' as any}); + expect(state).toContain({readonly: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({readonly: Infinity as any}); + expect(state).toContain({readonly: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({readonly: NaN as any}); + expect(state).toContain({readonly: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({readonly: {} as any}); + expect(state).toContain({readonly: false}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + }); + + test(`should ignore invalid 'tabindex' values`, () => { + expect(state).toContain({tabindex: 0}); + expect(stateChangeCount).toBe(1); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + rating.patch({tabindex: null as any}); + expect(state).toContain({tabindex: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + // note that this is not invalid, it only goes back to the default value + rating.patch({tabindex: undefined as any}); + expect(state).toContain({tabindex: 0}); + expect(stateChangeCount).toBe(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + rating.patch({tabindex: 'blah' as any}); + expect(state).toContain({tabindex: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({tabindex: Infinity as any}); + expect(state).toContain({tabindex: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({tabindex: NaN as any}); + expect(state).toContain({tabindex: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + + rating.patch({tabindex: {} as any}); + expect(state).toContain({tabindex: 0}); + expect(stateChangeCount).toBe(1); + expectLogInvalidValue(); + }); + + test(`should allow setting 'tabindex'`, () => { + expect(state).toContain({disabled: false, tabindex: 0}); + + rating.patch({tabindex: 1}); + expect(state).toContain({tabindex: 1}); + + rating.patch({disabled: true}); + expect(state).toContain({tabindex: -1}); + + rating.patch({disabled: false}); + expect(state).toContain({tabindex: 1}); + }); + + test(`should set rating/visibleRating within [0, maxRating]`, () => { + expect(state).toContain({rating: 0, maxRating: 10}); + + rating.patch({rating: 5}); + expect(state).toContain({rating: 5, visibleRating: 5, maxRating: 10}); + + rating.patch({rating: 100}); + expect(state).toContain({rating: 10, visibleRating: 10, maxRating: 10}); + + rating.patch({maxRating: 11}); + expect(state).toContain({rating: 10, visibleRating: 10, maxRating: 11}); + + rating.patch({rating: -100}); + expect(state).toContain({rating: 0, visibleRating: 0, maxRating: 11}); + }); + + test(`should generate star contexts correctly when 'rating' changes`, () => { + rating.patch({rating: 1, maxRating: 2}); + expect(state.stars).toEqual([ + { + fill: 100, + index: 0, + }, + { + fill: 0, + index: 1, + }, + ]); + + rating.patch({rating: 2, maxRating: 2}); + expect(state.stars).toEqual([ + { + fill: 100, + index: 0, + }, + { + fill: 100, + index: 1, + }, + ]); + + rating.patch({rating: 0, maxRating: 0}); + expect(state.stars).toEqual([]); + + rating.patch({rating: 1.75, maxRating: 2}); + expect(state.stars).toEqual([ + { + fill: 100, + index: 0, + }, + { + fill: 75, + index: 1, + }, + ]); + }); + + test(`should generate star contexts and visible rating correctly when 'hover()/leave()' changes`, () => { + const stars = [ + { + fill: 75, + index: 0, + }, + { + fill: 0, + index: 1, + }, + ]; + + rating.patch({rating: 0.75, maxRating: 2}); + expect(state.visibleRating).toBe(0.75); + expect(state.stars).toEqual(stars); + expect(hovers).toEqual([]); + expect(leaves).toEqual([]); + + // hover 2 + rating.actions.hover(2); + expect(hovers).toEqual([2]); + expect(leaves).toEqual([]); + expect(state.visibleRating).toBe(2); + expect(state.stars).toEqual([ + { + fill: 100, + index: 0, + }, + { + fill: 100, + index: 1, + }, + ]); + + // hover 1 + rating.actions.hover(1); + expect(hovers).toEqual([2, 1]); + expect(leaves).toEqual([]); + expect(state.visibleRating).toBe(1); + expect(state.stars).toEqual([ + { + fill: 100, + index: 0, + }, + { + fill: 0, + index: 1, + }, + ]); + + // leave + rating.actions.leave(); + expect(hovers).toEqual([2, 1]); + expect(leaves).toEqual([1]); + expect(state.visibleRating).toBe(0.75); + expect(state.stars).toEqual(stars); + expect(stateChangeCount).toBe(5); + + // hover -1 -> ignored + rating.actions.hover(-1); + expect(hovers).toEqual([2, 1]); + expect(leaves).toEqual([1]); + expect(state.visibleRating).toBe(0.75); + expect(state.stars).toEqual(stars); + expect(stateChangeCount).toBe(5); + + // hover 5 -> ignored + rating.actions.hover(5); + expect(hovers).toEqual([2, 1]); + expect(leaves).toEqual([1]); + expect(state.visibleRating).toBe(0.75); + expect(state.stars).toEqual(stars); + expect(stateChangeCount).toBe(5); + }); + + test(`should use 'ariaValueTextFn' to generate aria value text`, () => { + expect(state).toContain({rating: 0, maxRating: 10, ariaValueText: '0 out of 10'}); + + rating.patch({rating: 5}); + expect(state).toContain({ariaValueText: '5 out of 10'}); + + rating.patch({maxRating: 7}); + expect(state).toContain({ariaValueText: '5 out of 7'}); + + rating.patch({ariaValueTextFn: (rating: number, maxRating: number) => `${rating}/${maxRating}`}); + expect(state).toContain({ariaValueText: '5/7'}); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + rating.patch({ariaValueTextFn: null as any, rating: 4}); + expect(state).toContain({ariaValueText: '4/7'}); + expectLogInvalidValue(); + }); + + test(`should adjust rating within [0, 'maxRating'] when updating 'maxRating'`, () => { + rating.patch({rating: 5}); + expect(state).toContain({rating: 5, maxRating: 10}); + + rating.patch({maxRating: 2}); + expect(state).toContain({rating: 2, maxRating: 2}); + + rating.patch({rating: 5, maxRating: 10}); + expect(state).toContain({rating: 5, maxRating: 10}); + + rating.patch({maxRating: -100}); + expect(state).toContain({rating: 0, maxRating: 0}); + }); + + test(`should update rating when disabled via 'patch()'`, () => { + rating.patch({disabled: true}); + expect(state).toContain({disabled: true, readonly: false, rating: 0, maxRating: 10}); + + // allow values to be set + rating.patch({rating: 5, maxRating: 7}); + expect(state).toContain({rating: 5, maxRating: 7}); + }); + + test(`should update rating when readonly via 'patch()'`, () => { + rating.patch({readonly: true}); + expect(state).toContain({disabled: false, readonly: true, rating: 0, maxRating: 10}); + + // allow values to be set + rating.patch({rating: 5, maxRating: 7}); + expect(state).toContain({rating: 5, maxRating: 7}); + }); + + test(`should handle user 'click()' changes`, () => { + expect(state).toContain({rating: 0, maxRating: 10, resettable: true}); + expect(stateChangeCount).toBe(1); + + rating.actions.click(3); + expect(state).toContain({rating: 3}); + expect(stateChangeCount).toBe(2); + + rating.actions.click(0); + expect(state).toContain({rating: 3}); + expect(stateChangeCount).toBe(2); + + rating.actions.click(-1); + expect(state).toContain({rating: 3}); + expect(stateChangeCount).toBe(2); + + rating.actions.click(11); + expect(state).toContain({rating: 3}); + expect(stateChangeCount).toBe(2); + }); + + test(`should be 'resettable' or not`, () => { + rating.patch({rating: 5}); + expect(state).toContain({rating: 5, maxRating: 10, resettable: true}); + expect(stateChangeCount).toBe(2); + + rating.actions.click(5); + expect(state).toContain({rating: 0}); + expect(stateChangeCount).toBe(3); + + rating.patch({rating: 5, resettable: false}); + expect(state).toContain({rating: 5, resettable: false}); + expect(stateChangeCount).toBe(4); + + rating.actions.click(5); + expect(state).toContain({rating: 5}); + expect(stateChangeCount).toBe(4); + }); + + test(`should generate correct 'isInteractive' values`, () => { + expect(state).toContain({disabled: false, readonly: false, isInteractive: true}); + + rating.patch({disabled: true, readonly: false}); + expect(state).toContain({isInteractive: false}); + + rating.patch({disabled: false, readonly: true}); + expect(state).toContain({isInteractive: false}); + + rating.patch({disabled: true, readonly: true}); + expect(state).toContain({isInteractive: false}); + + rating.patch({disabled: false, readonly: false}); + expect(state).toContain({isInteractive: true}); + }); + + test(`should ignore user rating changes when disabled`, () => { + rating.patch({disabled: true, rating: 5}); + expect(state).toContain({disabled: true, readonly: false, rating: 5}); + expect(stateChangeCount).toBe(2); + + // user interactions should be ignored + rating.actions.click(3); + expect(state).toContain({rating: 5}); + expect(stateChangeCount).toBe(2); + }); + + test(`should ignore user rating changes when readonly`, () => { + rating.patch({readonly: true, rating: 5}); + expect(state).toContain({disabled: false, readonly: true, rating: 5}); + expect(stateChangeCount).toBe(2); + + // user interactions should be ignored + rating.actions.click(3); + expect(state).toContain({rating: 5}); + expect(stateChangeCount).toBe(2); + }); + + test(`should handle known keyboard events`, () => { + rating.patch({rating: 5}); + expect(state).toContain({disabled: false, readonly: false, rating: 5, maxRating: 10}); + + const evt = keyboardEvent(''); + const preventDefault = vi.spyOn(evt, 'preventDefault'); + const stopPropagation = vi.spyOn(evt, 'stopPropagation'); + + // Known keys + rating.actions.handleKey({...evt, key: 'ArrowLeft'}); + expect(state).toContain({rating: 4}); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(stopPropagation).toHaveBeenCalledTimes(1); + + rating.actions.handleKey({...evt, key: 'ArrowDown'}); + expect(state).toContain({rating: 3}); + expect(preventDefault).toHaveBeenCalledTimes(2); + expect(stopPropagation).toHaveBeenCalledTimes(2); + + rating.actions.handleKey({...evt, key: 'ArrowUp'}); + expect(state).toContain({rating: 4}); + expect(preventDefault).toHaveBeenCalledTimes(3); + expect(stopPropagation).toHaveBeenCalledTimes(3); + + rating.actions.handleKey({...evt, key: 'ArrowRight'}); + expect(state).toContain({rating: 5}); + expect(preventDefault).toHaveBeenCalledTimes(4); + expect(stopPropagation).toHaveBeenCalledTimes(4); + + rating.actions.handleKey({...evt, key: 'Home'}); + expect(state).toContain({rating: 0}); + expect(preventDefault).toHaveBeenCalledTimes(5); + expect(stopPropagation).toHaveBeenCalledTimes(5); + + rating.actions.handleKey({...evt, key: 'End'}); + expect(state).toContain({rating: 10}); + expect(preventDefault).toHaveBeenCalledTimes(6); + expect(stopPropagation).toHaveBeenCalledTimes(6); + }); + + test(`should ignore unknown keyboard events`, () => { + rating.patch({rating: 5}); + expect(state).toContain({disabled: false, readonly: false, rating: 5, maxRating: 10}); + + const evt = keyboardEvent('Enter'); + const preventDefault = vi.spyOn(evt, 'preventDefault'); + const stopPropagation = vi.spyOn(evt, 'stopPropagation'); + + // Unknown keys + rating.actions.handleKey(evt); + expect(state).toContain({rating: 5}); + expect(preventDefault).not.toHaveBeenCalled(); + expect(stopPropagation).not.toHaveBeenCalled(); + }); + + test(`should ignore known keyboard events when disabled or readonly`, () => { + rating.patch({rating: 5}); + expect(state).toContain({disabled: false, readonly: false, rating: 5, maxRating: 10}); + + const evt = keyboardEvent(''); + const preventDefault = vi.spyOn(evt, 'preventDefault'); + const stopPropagation = vi.spyOn(evt, 'stopPropagation'); + + // Disabled + rating.patch({readonly: false, disabled: true}); + rating.actions.handleKey({...evt, key: 'ArrowLeft'}); + expect(state).toContain({rating: 5}); + expect(preventDefault).toHaveBeenCalledTimes(0); + expect(stopPropagation).toHaveBeenCalledTimes(0); + + // Readonly + rating.patch({readonly: true, disabled: false}); + rating.actions.handleKey({...evt, key: 'ArrowLeft'}); + expect(state).toContain({rating: 5}); + expect(preventDefault).toHaveBeenCalledTimes(0); + expect(stopPropagation).toHaveBeenCalledTimes(0); + }); + + test(`should follow updated default config as long as it is not overridden`, () => { + defConfig.set({rating: 2}); + expect(state).toContain({rating: 2}); + defConfig.set({}); + expect(state).toContain({rating: 0}); + defConfig.set({rating: 5}); + expect(state).toContain({rating: 5}); + + rating.patch({maxRating: 4}); // this sets the rating to 4 + expect(state).toContain({rating: 4}); + defConfig.set({rating: 2}); // now this has no effect anymore + expect(state).toContain({rating: 4}); + + rating.patch({rating: undefined}); // resetting the own value to undefined allows to follow again the defaults + expect(state).toContain({rating: 2}); + }); + }); + + describe('without subscription on the state', () => { + test('should work when subscribing on visibleRating$ only', () => { + const values: number[] = []; + const visibleRatingValues: number[] = []; + const ratingWidget = createRating({ + maxRating: 10, + rating: 2, + onRatingChange(value) { + values.push(value); + }, + }); + const unsubscribe = ratingWidget.stores.visibleRating$.subscribe((value) => visibleRatingValues.push(value)); + expect(visibleRatingValues).toEqual([2]); + ratingWidget.actions.hover(8); + expect(visibleRatingValues).toEqual([2, 8]); + ratingWidget.actions.click(8); + expect(values).toEqual([8]); + unsubscribe(); + }); + }); +}); diff --git a/core/lib/rating.ts b/core/lib/rating.ts new file mode 100644 index 0000000000..5758f02153 --- /dev/null +++ b/core/lib/rating.ts @@ -0,0 +1,288 @@ +import {computed, writable} from '@amadeus-it-group/tansu'; +import type {ConfigValidator, PropsConfig} from './services'; +import {INVALID_VALUE, bindableDerived, stateStores, writablesForProps} from './services'; +import {isNumber} from './services/checks'; +import {typeBoolean, typeFunction, typeNumber, typeString} from './services/writables'; +import type {SlotContent, Widget} from './types'; + +export interface StarContext { + fill: number; + index: number; +} + +export interface RatingCommonPropsAndState { + /** + * The current rating. Could be a decimal value like `3.75`. + */ + rating: number; + + /** + * The maximum rating that can be given. + */ + maxRating: number; + + /** + * If `true`, the rating is disabled. + */ + disabled: boolean; + + /** + * If `true`, the rating can't be changed. + */ + readonly: boolean; + + /** + * Define if the rating can be reset. + * + * If set to true, the user can 'unset' the rating value by cliking on the current rating value. + */ + resettable: boolean; + + /** + * Allows setting a custom rating tabindex. + * If the component is disabled, `tabindex` will still be set to `-1`. + */ + tabindex: number; + + /** + * Classname to be applied on the rating container + */ + className: string; + + /** + * The template to override the way each star is displayed. + */ + slotStar: SlotContent; + + /** + * The aria label + */ + ariaLabel: string; + + /** + * The aria labelled by + */ + ariaLabelledBy: string; +} + +export interface RatingProps extends RatingCommonPropsAndState { + /** + * Return the value for the 'aria-value' attribute. + * @param rating - Current rating value. + * @param maxRating - maxRating value. + */ + ariaValueTextFn: (rating: number, maxRating: number) => string; + + /** + * An event emitted when the rating is changed. + * + * Event payload is equal to the newly selected rating. + */ + onRatingChange: (rating: number) => void; + + /** + * An event emitted when the user is hovering over a given rating. + * + * Event payload is equal to the rating being hovered over. + */ + onHover: (rating: number) => void; + + /** + * An event emitted when the user stops hovering over a given rating. + * + * Event payload is equal to the rating of the last item being hovered over. + */ + onLeave: (rating: number) => void; +} + +export interface RatingState extends RatingCommonPropsAndState { + ariaValueText: string; + visibleRating: number; + isInteractive: boolean; + stars: StarContext[]; +} + +export interface RatingActions { + /** + * Method to be used when a star is clicked. + * + * To be used in the onclick event of a star + * @param index - Star index, starting from 1 + */ + click(index: number): void; + + /** + * Method to be used when the mouse enter in a star. + * + * To be used in the onmouseenter of a star + * @param index - Star index, starting from 1 + */ + hover(index: number): void; + + /** + * Method to be used when the mouse leave the widget. + * + * To be used in the onmouseleave of the rating container + */ + leave(): void; + + /** + * Method to be used to handle the keyboard. + * + * To be used in the onkeydown of the rating container + */ + handleKey(event: KeyboardEvent): void; +} + +export type RatingWidget = Widget; + +// TODO use getValueInRange +function adjustRating(rating: number, maxRating: number): number { + return Math.max(Math.min(rating, maxRating), 0); +} + +const noop = () => {}; + +const defaultConfig: RatingProps = { + rating: 0, + tabindex: 0, + maxRating: 10, + disabled: false, + readonly: false, + resettable: true, + ariaValueTextFn: (rating: number, maxRating: number) => `${rating} out of ${maxRating}`, + onHover: noop, + onLeave: noop, + onRatingChange: noop, + className: '', + slotStar: ({fill}) => String.fromCharCode(fill === 100 ? 9733 : 9734), + ariaLabel: 'Rating', + ariaLabelledBy: '', +}; + +export function getRatingDefaultConfig() { + return {...defaultConfig}; +} + +// TODO export normalize function in utils and test them. +const configValidator: ConfigValidator = { + rating: typeNumber, + tabindex: typeNumber, + maxRating: {normalizeValue: (value) => (isNumber(value) ? Math.max(0, value) : INVALID_VALUE)}, + disabled: typeBoolean, + readonly: typeBoolean, + resettable: typeBoolean, + ariaValueTextFn: typeFunction, + onHover: typeFunction, + onLeave: typeFunction, + onRatingChange: typeFunction, + className: typeString, + ariaLabel: typeString, + ariaLabelledBy: typeString, +}; + +export function createRating(config?: PropsConfig): RatingWidget { + const [ + { + // dirty inputs that need adjustment: + rating$: _dirtyRating$, + tabindex$: _dirtyTabindex$, + + // clean inputs with value validation: + ariaValueTextFn$, + + onHover$, + onLeave$, + onRatingChange$, + + ...stateProps + }, + patch, + ] = writablesForProps(defaultConfig, config, configValidator); + const {maxRating$, disabled$, readonly$, resettable$} = stateProps; + + // clean inputs adjustment to valid range + const tabindex$ = computed(() => (disabled$() ? -1 : _dirtyTabindex$())); + + const rating$ = bindableDerived(onRatingChange$, [_dirtyRating$, maxRating$], ([dirtyRating, maxRating]) => adjustRating(dirtyRating, maxRating)); + + // internal inputs + const _hoveredRating$ = writable(0); + + // computed + const isInteractive$ = computed(() => !disabled$() && !readonly$()); + const visibleRating$ = computed(() => { + const rating = rating$(); // call rating unconditionnally (for the bindableDerived to stay active) + const hoveredRating = _hoveredRating$(); + return hoveredRating !== 0 ? hoveredRating : rating; + }); + const ariaValueText$ = computed(() => ariaValueTextFn$()(visibleRating$(), maxRating$())); + const stars$ = computed(() => { + const visibleRating = visibleRating$(); + return Array.from({length: maxRating$()}, (v, i) => ({ + fill: Math.round(Math.max(Math.min(visibleRating - i, 1), 0) * 100), + index: i, + })); + }); + + return { + ...stateStores({ + ariaValueText$, + isInteractive$, + rating$, + stars$, + tabindex$, + visibleRating$, + ...stateProps, + }), + patch, + actions: { + click: (index: number) => { + if (isInteractive$() && index > 0 && index <= maxRating$()) { + patch({rating: rating$() === index && resettable$() ? 0 : index}); + } + }, + hover: (index: number) => { + if (isInteractive$() && index > 0 && index <= maxRating$()) { + _hoveredRating$.set(index); + onHover$()(index); + } + }, + leave: () => { + if (isInteractive$()) { + onLeave$()(_hoveredRating$()); + _hoveredRating$.set(0); + } + }, + handleKey(event: KeyboardEvent) { + if (isInteractive$()) { + const {key} = event; + switch (key) { + case 'ArrowLeft': + case 'ArrowDown': + patch({rating: rating$() - 1}); + break; + case 'ArrowRight': + case 'ArrowUp': + patch({rating: rating$() + 1}); + break; + case 'Home': + case 'PageDown': + patch({rating: 0}); + break; + case 'End': + case 'PageUp': + patch({rating: maxRating$()}); + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + } + }, + }, + directives: {}, + api: {}, + }; +} diff --git a/core/lib/select.spec.ts b/core/lib/select.spec.ts new file mode 100644 index 0000000000..0eeefce657 --- /dev/null +++ b/core/lib/select.spec.ts @@ -0,0 +1,456 @@ +import {expect, test, describe, beforeEach, vi} from 'vitest'; +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import type {ItemCtx, SelectWidget, SelectProps} from './select'; +import {createSelect} from './select'; +import {createHasFocusMock} from './services/__mocks__/focustrack'; + +vi.mock('./services/focustrack'); + +type ExtractReadable = T extends ReadableSignal ? U : never; +type ExtractState = T extends SelectWidget ? ExtractReadable['state$']> : never; +/** + * Clone the object given in parameters. + * All props containing a function are removed, to ease object comparison. + */ +function cloneData(objectToBeCloned: any) { + if (!(objectToBeCloned instanceof Object)) { + return objectToBeCloned; + } + + let objectClone; + + const Constructor = objectToBeCloned.constructor; + switch (Constructor) { + case RegExp: + objectClone = new Constructor(objectToBeCloned); + break; + case Date: + objectClone = new Constructor(objectToBeCloned.getTime()); + break; + default: + objectClone = new Constructor(); + } + + for (const prop in objectToBeCloned) { + const value = objectToBeCloned[prop]; + if (typeof value !== 'function') { + objectClone[prop] = cloneData(value); + } + } + + return objectClone; +} + +describe(`Select model`, () => { + let setMockedFocus: ReturnType['setMockedFocus']; + beforeEach(() => { + setMockedFocus = createHasFocusMock().setMockedFocus; + setMockedFocus(true); + }); + + let props: Partial> & {items: any[]}; + let selectWidget: SelectWidget; + let states: Array>> | undefined> = []; + + /** + * Last state emitted by createSelect (value of createSelect().state$) + */ + let currentState: ExtractState> | undefined; + + /** + * currentState cloned (without functions) + */ + let currentStateCloned: Partial>> | undefined; + + function getProps(): Partial> & {items: string[]} { + return { + opened: false, + disabled: false, + filterText: '', + items: ['aa', 'aabb', 'bb'], + loading: false, + selected: [], + }; + } + + type State = ExtractState>; + function getStateClone(): State { + const stateClone = cloneData(currentState); + expect(stateClone).toBeDefined(); + return stateClone!; + } + + function highlightItem(state: State, itemIndex: number) { + const item = props.items[itemIndex]; + Object.assign(state.highlighted!, {item, id: item, selected: false}); + } + + beforeEach(() => { + props = getProps(); + selectWidget = createSelect(); + selectWidget.patch(props); + + states = []; + currentState = undefined; + return selectWidget.state$.subscribe((value) => { + const clonedValue = cloneData(value); + states.push(clonedValue); + currentStateCloned = clonedValue; + currentState = value; + }); + }); + + describe(`simple list`, () => { + test(`State when 'opened' is false`, () => { + expect(currentState, 'State when opened is false').toMatchInlineSnapshot(` + { + "className": "", + "disabled": false, + "filterText": "", + "highlighted": undefined, + "loading": false, + "opened": false, + "selected": [], + "visible": [], + } + `); + expect(states.length, 'total number of calls').toBe(1); + }); + + test(`State after opening the dropdown`, () => { + selectWidget.api.open(); + expect(getStateClone(), 'initial state check').toMatchInlineSnapshot(` + { + "className": "", + "disabled": false, + "filterText": "", + "highlighted": { + "id": "aa", + "item": "aa", + "selected": false, + }, + "loading": false, + "opened": true, + "selected": [], + "visible": [ + { + "id": "aa", + "item": "aa", + "selected": false, + }, + { + "id": "aabb", + "item": "aabb", + "selected": false, + }, + { + "id": "bb", + "item": "bb", + "selected": false, + }, + ], + } + `); + + expect(states.length, 'total number of calls').toBe(2); + }); + + test(`closes when losing the focus`, () => { + expect(currentState!.opened, 'Initially closed').toBe(false); + + selectWidget.api.open(); + expect(currentState!.opened).toBe(true); + + setMockedFocus(false); + expect(currentState!.opened).toBe(false); + + setMockedFocus(true); + expect(currentState!.opened, 'stay close if focus is back').toBe(false); + }); + + test(`toggle open/close with the api`, () => { + expect(currentState!.opened, 'Initially closed').toBe(false); + + selectWidget.api.open(); + expect(currentState!.opened).toBe(true); + + selectWidget.api.close(); + expect(currentState!.opened).toBe(false); + + selectWidget.api.toggle(); + expect(currentState!.opened).toBe(true); + + selectWidget.api.toggle(true); + expect(currentState!.opened, 'Toggle with open forced to true').toBe(true); + + selectWidget.api.toggle(); + expect(currentState!.opened).toBe(false); + }); + + test(`filtering`, () => { + selectWidget.api.open(); + const expectedState = getStateClone(); + let text = 'bb'; + + Object.assign(expectedState, { + filterText: text, + highlighted: {id: 'aabb', item: 'aabb', selected: false}, + visible: expectedState.visible.filter(({item}) => item.includes(text)), + }); + + selectWidget.patch({filterText: text}); + expect(currentStateCloned, 'state after text filtering').toEqual(expectedState); + + text = 'Bb'; + Object.assign(expectedState, { + filterText: text, + }); + + selectWidget.patch({filterText: text}); + expect(currentStateCloned, 'filtering with different case').toEqual(expectedState); + }); + + test(`Functionnal api for highlighted item`, () => { + selectWidget.api.open(); + const expectedState = getStateClone(); + + selectWidget.api.highlightLast(); + highlightItem(expectedState, 2); + expect(currentStateCloned, 'highlightLast api').toEqual(expectedState); + + selectWidget.api.highlightFirst(); + highlightItem(expectedState, 0); + expect(currentStateCloned, 'highlightFirst api').toEqual(expectedState); + + selectWidget.api.highlightNext(); + highlightItem(expectedState, 1); + expect(currentStateCloned, 'highlightNext api').toEqual(expectedState); + + selectWidget.api.highlightPrevious(); + highlightItem(expectedState, 0); + expect(currentStateCloned, 'highlightPrevious api').toEqual(expectedState); + + selectWidget.api.highlightPrevious(); + highlightItem(expectedState, 2); + expect(currentStateCloned, 'highlightPrevious api with cycle').toEqual(expectedState); + + selectWidget.api.highlightNext(); + highlightItem(expectedState, 0); + expect(currentStateCloned, 'highlightNext api with cycle').toEqual(expectedState); + + const item = props.items[2]; + selectWidget.api.highlight(item); + highlightItem(expectedState, 2); + expect(currentStateCloned, 'highlight api').toEqual(expectedState); + + selectWidget.api.highlight('not in list'); + expectedState.highlighted = undefined; + expect(currentStateCloned, 'highlight api with a non existant item').toEqual(expectedState); + }); + }); + + describe(`Keyboard management`, () => { + let preventDefaultCall = 0; + function createEvent({ctrlKey = false} = {}) { + preventDefaultCall = 0; + return { + key: '', + ctrlKey, + preventDefault() { + preventDefaultCall++; + }, + }; + } + + test('Arrow keys', () => { + const event = createEvent(); + let preventDefaultCallNb = 0; + + event.key = 'ArrowDown'; + selectWidget.actions.onInputKeydown(event); + const expectedState = getStateClone(); + + highlightItem(expectedState, 0); + expect(currentStateCloned, 'Menu opened with arrow down').toEqual(expectedState); + expect(preventDefaultCall).toBe(++preventDefaultCallNb); + + event.key = 'ArrowDown'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 1); + expect(currentStateCloned, 'Move to the second item').toEqual(expectedState); + expect(preventDefaultCall).toBe(++preventDefaultCallNb); + + event.key = 'ArrowUp'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 0); + expect(currentStateCloned, 'Move back to the first item').toEqual(expectedState); + expect(preventDefaultCall).toBe(++preventDefaultCallNb); + + event.key = 'ArrowUp'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 2); + expect(currentStateCloned, 'Loop to the last item').toEqual(expectedState); + expect(preventDefaultCall).toBe(++preventDefaultCallNb); + + event.key = 'ArrowDown'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 0); + expect(currentStateCloned, 'Loop back to the first item').toEqual(expectedState); + expect(preventDefaultCall).toBe(++preventDefaultCallNb); + + event.key = 'A'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 0); + expect(currentStateCloned).toEqual(expectedState); + expect(preventDefaultCall, 'Random text is not prevented').toBe(preventDefaultCallNb); + }); + + test('Arrow keys with Ctrl', () => { + selectWidget.api.open(); + const expectedState = getStateClone(); + const event = createEvent({ctrlKey: true}); + + event.key = 'ArrowDown'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 2); + expect(currentStateCloned, 'Move to the last item').toEqual(expectedState); + expect(preventDefaultCall).toBe(1); + + event.key = 'ArrowDown'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 2); + expect(currentStateCloned, 'Second action does not loop').toEqual(expectedState); + expect(preventDefaultCall).toBe(2); + + event.key = 'ArrowUp'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 0); + expect(currentStateCloned, 'Move to the first item').toEqual(expectedState); + expect(preventDefaultCall).toBe(3); + + event.key = 'ArrowUp'; + selectWidget.actions.onInputKeydown(event); + highlightItem(expectedState, 0); + expect(currentStateCloned, 'Second action does not loop').toEqual(expectedState); + expect(preventDefaultCall).toBe(4); + }); + + test('Change the selection with enter', () => { + const event = createEvent(); + event.key = 'ArrowDown'; + selectWidget.actions.onInputKeydown(event); + const expectedState = getStateClone(); + Object.assign(expectedState.highlighted!, {item: props.items[0], selected: false}); + expect(currentStateCloned, 'Menu opened with arrow down').toEqual(expectedState); + + event.key = 'Enter'; + selectWidget.actions.onInputKeydown(event); + const expectedStateAfterSelection = cloneData(expectedState); + const itemCtx: ItemCtx = expectedStateAfterSelection.highlighted!; + itemCtx.selected = true; + expectedStateAfterSelection.selected = [itemCtx.item]; + expectedStateAfterSelection.visible[0].selected = true; + Object.assign(expectedStateAfterSelection.highlighted!, {item: props.items[0], selected: true}); + expect(currentStateCloned, 'First item selected').toEqual(expectedStateAfterSelection); + + event.key = 'Enter'; + selectWidget.actions.onInputKeydown(event); + expect(currentStateCloned, 'Back to first state after un-selection').toEqual(expectedState); + }); + + test('Close with escape', () => { + selectWidget.api.open(); + const expectedState = getStateClone(); + const event = createEvent(); + + event.key = 'Escape'; + Object.assign(expectedState, { + opened: false, + highlighted: undefined, + visible: [], + }); + selectWidget.actions.onInputKeydown(event); + expect(currentState, 'Move to the last item').toEqual(expectedState); + expect(preventDefaultCall).toBe(1); + }); + }); + describe(`item click`, () => { + test(`change the selection with the global api`, () => { + selectWidget.api.open(); + const expectedState = getStateClone(); + + selectWidget.api.select('Not in list'); + expect(currentStateCloned, `unknown item don't change the selection`).toEqual(expectedState); + + const item0 = 'aa'; + const item1 = 'aabb'; + selectWidget.api.select(item1); + expectedState.selected = [item1]; + expectedState.visible[1].selected = true; + expect(currentStateCloned, 'Select item').toEqual(expectedState); + + selectWidget.api.select(item1); + expect(currentStateCloned, `Don't duplicate the selected item`).toEqual(expectedState); + + selectWidget.api.select(item0); + expectedState.selected = [item1, item0]; + expectedState.visible[0].selected = true; + expectedState.highlighted!.selected = true; + expect(currentStateCloned, `select two items and keep the selection order`).toEqual(expectedState); + + selectWidget.api.unselect(item1); + expectedState.selected = [item0]; + expectedState.visible[1].selected = false; + expect(currentStateCloned, `Unselect item`).toEqual(expectedState); + + selectWidget.api.toggleItem(item0); + expectedState.selected = []; + expectedState.visible[0].selected = false; + expectedState.highlighted!.selected = false; + expect(currentStateCloned, `Toggle item`).toEqual(expectedState); + }); + + test(`change the selection with the item api`, () => { + selectWidget.api.open(); + const expectedState = getStateClone(); + const item0 = 'aa'; + const item1 = 'aabb'; + currentState!.visible[1].select(); + expectedState.selected = [item1]; + expectedState.visible[1].selected = true; + expect(currentStateCloned, 'Select item').toEqual(expectedState); + + currentState!.visible[1].select(); + expect(currentStateCloned, `Don't duplicate the selected item`).toEqual(expectedState); + + currentState!.visible[0].select(); + expectedState.selected = [item1, item0]; + expectedState.visible[0].selected = true; + expectedState.highlighted!.selected = true; + expect(currentStateCloned, `select two items and keep the selection order`).toEqual(expectedState); + + currentState!.visible[1].unselect(); + expectedState.selected = [item0]; + expectedState.visible[1].selected = false; + expect(currentStateCloned, `Unselect item`).toEqual(expectedState); + + currentState!.visible[0].toggle(); + expectedState.selected = []; + expectedState.visible[0].selected = false; + expectedState.highlighted!.selected = false; + expect(currentStateCloned, `Toggle item`).toEqual(expectedState); + }); + + test(`clear the selection`, () => { + selectWidget.api.open(); + const expectedState = getStateClone(); + + // Select then clear should return to the initial state + selectWidget.api.select('aabb'); + selectWidget.api.select('aa'); + selectWidget.api.clear(); + + expect(currentStateCloned, 'After clear').toEqual(expectedState); + }); + }); +}); diff --git a/core/lib/select.ts b/core/lib/select.ts new file mode 100644 index 0000000000..f683f00ce5 --- /dev/null +++ b/core/lib/select.ts @@ -0,0 +1,448 @@ +import {asReadable, batch, computed, writable} from '@amadeus-it-group/tansu'; +import type {HasFocus} from './services/focustrack'; +import {createHasFocus} from './services/focustrack'; +import type {PropsConfig} from './services/stores'; +import {stateStores, writablesForProps} from './services/stores'; +import type {Widget} from './types'; + +export interface SelectCommonPropsAndState { + className: string; + + /** + * List of selected items + */ + selected: Item[]; + + /** + * Filtered text to be display in the filter input + */ + filterText: string; + + /** + * true if the select is disabled + */ + disabled: boolean; + + /** + * true if the select is open + */ + opened: boolean; + + /** + * true if a loading process is being done + */ + loading: boolean; +} + +export interface SelectProps extends SelectCommonPropsAndState { + /** + * List of available items for the dropdown + */ + items: T[]; + + /** + * Custom function to filter an item. + * By default, item is considered as a string, and the function returns true if the text is found + */ + matchFn(item: T, text: string): boolean; + + /** + * Custom function to get the id of an item + * By default, the item is returned + */ + itemId(item: T): string; + + // Event callbacks + + /** + * Callback called when the text filter change + * @param text - Filtered text + */ + onFilterTextChange?(text: string): void; +} + +/** + * Item representation built from the items provided in parameters + */ +export interface ItemCtx { + /** + * Original item given in the parameters + */ + item: T; + + /** + * Unique id to identify the item + */ + id: string; + + /** + * Specify if the item is checked + */ + selected: boolean; + + /** + * Select the item + */ + select(): void; + + /** + * Unselect the item + */ + unselect(): void; + + /** + * Toggle the item selection + */ + toggle(): void; +} + +export interface SelectState extends SelectCommonPropsAndState { + /** + * List of visible items displayed in the menu + */ + visible: ItemCtx[]; + + /** + * Highlighted item context. + * It is designed to define the highlighted item in the dropdown menu + */ + highlighted: ItemCtx | undefined; +} + +export interface SelectApi { + /** + * Clear all the selected items + */ + clear(): void; + + /** + * Clear the filter text + */ + clearText(): void; + + /** + * Highlight the given item, if there is a corresponding match among the visible list + */ + highlight(item: Item): void; + + /** + * Highlight the first item among the visible list + */ + highlightFirst(): void; + + /** + * Highlight the previous item among the visible list + * Loop to the last item if needed + */ + highlightPrevious(): void; + + /** + * Highlight the next item among the visible list. + * Loop to the first item if needed + */ + highlightNext(): void; + + /** + * Highlight the last item among the visible list + */ + highlightLast(): void; + + /** + * Focus the provided item among the selected list. + * The focus feature is designed to know what item must be focused in the UI, i.e. among the badge elements. + */ + focus(item: Item): void; + focusFirst(): void; + focusPrevious(): void; + focusNext(): void; + focusLast(): void; + + /** + * Select the provided item. + * The selected list is used to + */ + select(item: Item): void; + unselect(item: Item): void; + toggleItem(item: Item, selected?: boolean): void; + + open(): void; + close(): void; + /** + * Toggle the dropdown menu + * @param isOpen - If specified, set the menu in the defined state. + */ + toggle(isOpen?: boolean): void; +} + +export interface SelectDirectives { + /** + * Directive to be used in the input group and the menu containers + */ + hasFocusDirective: HasFocus['directive']; +} + +export interface SelectActions { + // Dom methods + + /** + * Method to be plugged to on the 'input' event. The input text will be used as the filter text. + */ + onInput: (e: {target: any}) => void; + + /** + * Method to be plugged to on an keydown event, in order to control the keyboard interactions with the highlighted item. + * It manages arrow keys to move the highlighted item, or enter to toggle the item. + */ + onInputKeydown: (e: any) => void; +} + +export type SelectWidget = Widget, SelectState, SelectApi, SelectActions, SelectDirectives>; + +function defaultMatchFn(item: any, text: string) { + return JSON.stringify(item).toLowerCase().includes(text.toLowerCase()); +} + +function defaultItemId(item: any) { + return '' + item; +} + +const defaultConfig: SelectProps = { + opened: false, + disabled: false, + items: [], + filterText: '', + loading: false, + selected: [], + itemId: defaultItemId, + matchFn: defaultMatchFn, + onFilterTextChange: undefined, + className: '', +}; + +export function createSelect(config?: PropsConfig>): SelectWidget { + // Props + const [{opened$: _dirtyOpened$, items$, itemId$, matchFn$, onFilterTextChange$, ...otherProps}, patch] = writablesForProps>( + defaultConfig, + config + ); + const {selected$, filterText$} = otherProps; + + const {hasFocus$, directive: hasFocusDirective} = createHasFocus(); + const opened$ = computed(() => { + const _dirtyOpened = _dirtyOpened$(); + const hasFocus = hasFocus$(); + if (!hasFocus && _dirtyOpened) { + _dirtyOpened$.set(false); + } + return _dirtyOpened && hasFocus; + }); + + const highlightedIndex$ = (function () { + const store = writable(0); + + const newStore = asReadable(store, { + set(index: number | undefined) { + const {length} = visible$(); + if (index != undefined) { + if (!length) { + index = undefined; + } else if (index < 0) { + index = length - 1; + } else if (index >= length) { + index = 0; + } + } + store.set(index); + }, + update(fn: (index: number | undefined) => number | undefined) { + newStore.set(fn(store())); + }, + }); + + return newStore; + })(); + + const visible$ = computed(() => { + const list: ItemCtx[] = []; + if (opened$()) { + const selected = selected$(); + const filterText = filterText$(); + const matchFn = !filterText ? () => true : matchFn$(); + const itemId = itemId$(); + for (const item of items$()) { + if (matchFn(item, filterText)) { + list.push({ + item, + id: itemId(item), + selected: selected.includes(item), + select: function (this: Item) { + widget.api.select(this); + }.bind(item), + unselect: function (this: Item) { + widget.api.unselect(this); + }.bind(item), + toggle: function (this: Item) { + widget.api.toggleItem(this); + }.bind(item), + }); + } + } + } + return list; + }); + + const highlighted$ = computed(() => { + const visible = visible$(); + const highlightedIndex = highlightedIndex$(); + return visible.length && highlightedIndex != undefined ? visible[highlightedIndex] : undefined; + }); + + const widget: SelectWidget = { + ...stateStores({ + visible$, + highlighted$, + opened$, + ...otherProps, + }), + + patch, + api: { + clear() { + selected$.set([]); + }, + + select(item: Item) { + widget.api.toggleItem(item, true); + }, + unselect(item: Item) { + widget.api.toggleItem(item, false); + }, + toggleItem(item: Item, selected?: boolean) { + if (!items$().includes(item)) { + return; + } + selected$.update((selectedItems) => { + selectedItems = [...selectedItems]; + const index = selectedItems.indexOf(item); + if (selected == null) { + selected = index === -1; + } + if (selected && index === -1) { + selectedItems.push(item); + } else if (!selected && index !== -1) { + selectedItems.splice(index, 1); + } + + return selectedItems; + }); + }, + + clearText() { + // FIXME: not implemented yet! + }, + + highlight(item: Item) { + const index = visible$().findIndex((itemCtx) => itemCtx.item === item); + highlightedIndex$.set(index === -1 ? undefined : index); + }, + highlightFirst() { + highlightedIndex$.set(0); + }, + highlightPrevious() { + highlightedIndex$.update((highlightedIndex) => { + return highlightedIndex != null ? highlightedIndex - 1 : -1; + }); + }, + highlightNext() { + highlightedIndex$.update((highlightedIndex) => { + return highlightedIndex != null ? highlightedIndex + 1 : Infinity; + }); + }, + highlightLast() { + highlightedIndex$.set(-1); + }, + + focus(item: Item) { + // FIXME: not implemented yet! + }, + focusFirst() { + // FIXME: not implemented yet! + }, + focusPrevious() { + // FIXME: not implemented yet! + }, + focusNext() { + // FIXME: not implemented yet! + }, + focusLast() { + // FIXME: not implemented yet! + }, + + open: () => widget.api.toggle(true), + close: () => widget.api.toggle(false), + toggle(isOpen?: boolean) { + _dirtyOpened$.update((value) => (isOpen != null ? isOpen : !value)); + }, + }, + directives: { + hasFocusDirective, + }, + actions: { + onInput({target}: {target: HTMLInputElement}) { + const value = target.value; + batch(() => { + patch({ + opened: value != null && value !== '', + filterText: value, + }); + onFilterTextChange$()?.(value); + }); + }, + onInputKeydown(e: KeyboardEvent) { + const {ctrlKey, key} = e; + + let keyManaged = true; + switch (key) { + case 'ArrowDown': { + const isOpen = opened$(); + if (isOpen) { + if (ctrlKey) { + widget.api.highlightLast(); + } else { + widget.api.highlightNext(); + } + } else { + widget.api.open(); + widget.api.highlightFirst(); + } + break; + } + case 'ArrowUp': + if (ctrlKey) { + widget.api.highlightFirst(); + } else { + widget.api.highlightPrevious(); + } + break; + case 'Enter': { + const itemCtx = highlighted$(); + if (itemCtx) { + widget.api.toggleItem(itemCtx.item); + } + break; + } + case 'Escape': + _dirtyOpened$.set(false); + break; + default: + keyManaged = false; + } + if (keyManaged) { + e.preventDefault(); + } + }, + }, + }; + + return widget; +} diff --git a/core/lib/services/__mocks__/focustrack.ts b/core/lib/services/__mocks__/focustrack.ts new file mode 100644 index 0000000000..230e46d166 --- /dev/null +++ b/core/lib/services/__mocks__/focustrack.ts @@ -0,0 +1,25 @@ +import {asReadable, writable} from '@amadeus-it-group/tansu'; +import {vi} from 'vitest'; +import type {HasFocus, createHasFocus as realCreateHasFocus} from '../focustrack'; + +export const createHasFocus = vi.fn, ReturnType>(); + +const noop = () => { + /* empty function */ +}; + +export function createHasFocusMock() { + const hasFocusWritable = writable(false); + const hasFocus: HasFocus = { + directive: noop, + hasFocus$: asReadable(hasFocusWritable), + }; + createHasFocus.mockReset().mockReturnValue(hasFocus); + + return { + setMockedFocus(isFocused: boolean) { + hasFocusWritable.set(isFocused); + }, + hasFocus, + }; +} diff --git a/core/lib/services/checks.spec.ts b/core/lib/services/checks.spec.ts new file mode 100644 index 0000000000..6f3581d288 --- /dev/null +++ b/core/lib/services/checks.spec.ts @@ -0,0 +1,98 @@ +import {describe, expect, test} from 'vitest'; +import {isBoolean, isFunction, isNumber, getValueInRange, isString} from './checks'; + +describe('Checks', () => { + test(`'isNumber' should check if value is a number`, () => { + expect(isNumber(0)).toBe(true); + expect(isNumber(1)).toBe(true); + expect(isNumber(1.1)).toBe(true); + expect(isNumber('1')).toBe(false); + expect(isNumber('0')).toBe(false); + expect(isNumber('1.1')).toBe(false); + expect(isNumber(undefined)).toBe(false); + expect(isNumber(null)).toBe(false); + expect(isNumber(true)).toBe(false); + expect(isNumber({})).toBe(false); + expect(isNumber([])).toBe(false); + expect(isNumber(NaN)).toBe(false); + expect(isNumber(Infinity)).toBe(false); + expect(isNumber(-Infinity)).toBe(false); + expect(isNumber(() => {})).toBe(false); + }); + + test(`'isBoolean' should check if value is a boolean`, () => { + expect(isBoolean(true)).toBe(true); + expect(isBoolean(false)).toBe(true); + expect(isBoolean(0)).toBe(false); + expect(isBoolean(1)).toBe(false); + expect(isBoolean(1.1)).toBe(false); + expect(isBoolean('1')).toBe(false); + expect(isBoolean('0')).toBe(false); + expect(isBoolean('1.1')).toBe(false); + expect(isBoolean(undefined)).toBe(false); + expect(isBoolean(null)).toBe(false); + expect(isBoolean({})).toBe(false); + expect(isBoolean([])).toBe(false); + expect(isBoolean(NaN)).toBe(false); + expect(isBoolean(Infinity)).toBe(false); + expect(isBoolean(-Infinity)).toBe(false); + expect(isBoolean(() => {})).toBe(false); + }); + + test(`'isFunction' should check if value is a function`, () => { + expect(isFunction(function () {})).toBe(true); + expect(isFunction(class {})).toBe(true); + expect(isFunction(true)).toBe(false); + expect(isFunction(false)).toBe(false); + expect(isFunction(0)).toBe(false); + expect(isFunction(1)).toBe(false); + expect(isFunction(1.1)).toBe(false); + expect(isFunction('1')).toBe(false); + expect(isFunction('0')).toBe(false); + expect(isFunction('1.1')).toBe(false); + expect(isFunction(undefined)).toBe(false); + expect(isFunction(null)).toBe(false); + expect(isFunction({})).toBe(false); + expect(isFunction([])).toBe(false); + expect(isFunction(NaN)).toBe(false); + expect(isFunction(Infinity)).toBe(false); + expect(isFunction(-Infinity)).toBe(false); + expect(isFunction(() => {})).toBe(true); + }); + + test(`'isString' should check if value is a string`, () => { + expect(isString(true)).toBe(false); + expect(isString(false)).toBe(false); + expect(isString(0)).toBe(false); + expect(isString(1)).toBe(false); + expect(isString(1.1)).toBe(false); + expect(isString('1')).toBe(true); + expect(isString('0')).toBe(true); + expect(isString('1.1')).toBe(true); + expect(isString(undefined)).toBe(false); + expect(isString(null)).toBe(false); + expect(isString({})).toBe(false); + expect(isString([])).toBe(false); + expect(isString(NaN)).toBe(false); + expect(isString(Infinity)).toBe(false); + expect(isString(-Infinity)).toBe(false); + expect(isString(() => {})).toBe(false); + }); + + test(`'getValueInRange' should return a value is within a specific range`, () => { + expect(getValueInRange(1, 5)).toBe(1); + expect(getValueInRange(-1, 5)).toBe(0); + expect(getValueInRange(0, 5)).toBe(0); + expect(getValueInRange(-Infinity, 5)).toBe(0); + expect(getValueInRange(-2022, 5)).toBe(0); + // Max could be < min + expect(getValueInRange(5, -5)).toBe(0); + expect(getValueInRange(Infinity, 5)).toBe(5); + expect(getValueInRange(2022, 5)).toBe(5); + expect(getValueInRange(0, 10, 5)).toBe(5); + expect(getValueInRange(5, 10, 5)).toBe(5); + expect(getValueInRange(6, 10, 5)).toBe(6); + expect(getValueInRange(10, 10, 5)).toBe(10); + expect(getValueInRange(2022, 10, 5)).toBe(10); + }); +}); diff --git a/core/lib/services/checks.ts b/core/lib/services/checks.ts new file mode 100644 index 0000000000..270cba9e4c --- /dev/null +++ b/core/lib/services/checks.ts @@ -0,0 +1,20 @@ +export function isNumber(value: any): value is number { + return typeof value === 'number' && !isNaN(value) && Number.isFinite(value); +} + +export function isBoolean(value: any): value is boolean { + return value === true || value === false; +} + +export function isFunction(value: any): value is (...args: any[]) => any { + return typeof value === 'function'; +} + +export function isString(value: any): value is string { + return typeof value === 'string'; +} + +// TODO should we check that max > min? +export function getValueInRange(value: number, max: number, min = 0): number { + return Math.max(Math.min(value, max), min); +} diff --git a/core/lib/services/directiveUtils.spec.ts b/core/lib/services/directiveUtils.spec.ts new file mode 100644 index 0000000000..f31d762b3f --- /dev/null +++ b/core/lib/services/directiveUtils.spec.ts @@ -0,0 +1,269 @@ +import {asReadable, computed, readable, writable} from '@amadeus-it-group/tansu'; +import type {SpyInstance} from 'vitest'; +import {beforeEach, describe, expect, it, vitest} from 'vitest'; +import { + bindDirective, + bindDirectiveNoArg, + createStoreArrayDirective, + createStoreDirective, + directiveSubscribe, + directiveUpdate, + mergeDirectives, + registrationArray, +} from './directiveUtils'; + +describe('directiveUtils', () => { + let element: HTMLElement; + let consoleErrorSpy: SpyInstance, ReturnType>; + + beforeEach(() => { + consoleErrorSpy = vitest.spyOn(console, 'error').mockImplementation(() => {}); + element = document.createElement('div'); + return () => { + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }; + }); + + describe('bindDirective', () => { + it('Basic functionalities', () => { + const store = writable(1); + const directiveArg$ = asReadable(store); + vitest.spyOn(directiveArg$, 'subscribe'); + const directive = vitest.fn((element, value) => ({destroy: vitest.fn(), update: vitest.fn()})); + const boundDirective = bindDirective(directive, directiveArg$); + expect(directive).not.toHaveBeenCalled(); + expect(directiveArg$.subscribe).not.toHaveBeenCalled(); + const boundDirectiveInstance = boundDirective(element); + const directiveInstance = directive.mock.results[0].value; + expect(directive).toHaveBeenCalledOnce(); + expect(directiveArg$.subscribe).toHaveBeenCalledOnce(); + expect(directive).toHaveBeenCalledWith(element, 1); + expect(directiveInstance.update).not.toHaveBeenCalled(); + store.set(5); + expect(directiveInstance.update).toHaveBeenCalledOnce(); + expect(directiveInstance.update).toHaveBeenCalledWith(5); + expect(directiveInstance.destroy).not.toHaveBeenCalled(); + boundDirectiveInstance?.destroy?.(); + expect(directiveInstance.destroy).toHaveBeenCalledOnce(); + }); + }); + + describe('bindDirectiveNoArg', () => { + it('Basic functionalities', () => { + const directive = vitest.fn((element, value) => ({destroy: vitest.fn(), update: vitest.fn()})); + const boundDirective = bindDirectiveNoArg(directive); + expect(directive).not.toHaveBeenCalled(); + const boundDirectiveInstance = boundDirective(element, 1 as any); // the argument should be ignored + const directiveInstance = directive.mock.results[0].value; + expect(directive).toHaveBeenCalledOnce(); + expect(directive.mock.calls[0][0]).toBe(element); + expect(directive.mock.calls[0][1]).toBe(undefined); + boundDirectiveInstance?.update?.(5 as any); // should not call update on directiveInstance + expect(directiveInstance.update).not.toHaveBeenCalled(); + expect(directiveInstance.destroy).not.toHaveBeenCalled(); + boundDirectiveInstance?.destroy?.(); + expect(directiveInstance.destroy).toHaveBeenCalledOnce(); + }); + }); + + describe('directiveSubscribe', () => { + it('Basic functionalities, asynchronous (default behavior)', async () => { + let hasSubscribers = false; + const store = readable(0, () => { + hasSubscribers = true; + return () => { + hasSubscribers = false; + }; + }); + const directive = directiveSubscribe(store); + expect(hasSubscribers).toBe(false); + const instance = directive(element); + expect(hasSubscribers).toBe(true); + instance?.destroy?.(); + expect(hasSubscribers).toBe(true); + await 0; + expect(hasSubscribers).toBe(false); + }); + + it('Basic functionalities, synchronous', () => { + let hasSubscribers = false; + const store = readable(0, () => { + hasSubscribers = true; + return () => { + hasSubscribers = false; + }; + }); + const directive = directiveSubscribe(store, false); + expect(hasSubscribers).toBe(false); + const instance = directive(element); + expect(hasSubscribers).toBe(true); + instance?.destroy?.(); + expect(hasSubscribers).toBe(false); + }); + }); + + describe('directiveUpdate', () => { + it('Basic functionalities', () => { + const update = vitest.fn((num: number) => {}); + const directive = directiveUpdate(update); + expect(update).not.toHaveBeenCalled(); + const instance = directive(element, 1); + expect(update).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledWith(1); + instance?.update?.(2); + expect(update).toHaveBeenCalledTimes(2); + expect(update).toHaveBeenCalledWith(2); + instance?.destroy?.(); + expect(update).toHaveBeenCalledTimes(2); + }); + }); + + describe('registrationArray', () => { + it('Basic functionalities', () => { + const array$ = registrationArray(); + const values: number[][] = []; + const unsubscribe = array$.subscribe((value) => values.push(value)); + expect(values).toEqual([[]]); + const unregister1 = array$.register(7); + expect(values).toEqual([[], [7]]); + const unregister2 = array$.register(8); + expect(values).toEqual([[], [7], [7, 8]]); + unregister1(); + expect(values).toEqual([[], [7], [7, 8], [8]]); + unregister2(); + expect(values).toEqual([[], [7], [7, 8], [8], []]); + unsubscribe(); + }); + }); + + describe('createStoreArrayDirective', () => { + it('Basic functionalities', () => { + const {directive, elements$} = createStoreArrayDirective(); + const values: HTMLElement[][] = []; + const unsubscribe = elements$.subscribe((value) => values.push(value)); + expect(values).toEqual([[]]); + const instance1 = directive(element); + expect(values).toEqual([[], [element]]); + const element2 = document.createElement('span'); + const instance2 = directive(element2); + expect(values).toEqual([[], [element], [element, element2]]); + instance1?.destroy?.(); + expect(values).toEqual([[], [element], [element, element2], [element2]]); + instance2?.destroy?.(); + expect(values).toEqual([[], [element], [element, element2], [element2], []]); + unsubscribe(); + }); + + it('should behave correctly with the same directive twice on the same item and destroying twice the same directive', () => { + const {directive, elements$} = createStoreArrayDirective(); + const values: HTMLElement[][] = []; + const unsubscribe = elements$.subscribe((value) => values.push(value)); + expect(values).toEqual([[]]); + const instance1 = directive(element); + expect(values).toEqual([[], [element]]); + const instance2 = directive(element); + expect(values).toEqual([[], [element], [element, element]]); + instance1?.destroy?.(); + expect(values).toEqual([[], [element], [element, element], [element]]); + instance1?.destroy?.(); // destroying again the same directive should be a noop + expect(values).toEqual([[], [element], [element, element], [element]]); + instance2?.destroy?.(); + expect(values).toEqual([[], [element], [element, element], [element], []]); + unsubscribe(); + }); + }); + + describe('createStoreDirective', () => { + it('Basic functionalities', () => { + const {directive, element$} = createStoreDirective(); + const values: (HTMLElement | null)[] = []; + const unsubscribe = element$.subscribe((value) => values.push(value)); + expect(values).toEqual([null]); + const instance = directive(element); + expect(values).toEqual([null, element]); + instance?.destroy?.(); + expect(values).toEqual([null, element, null]); + unsubscribe(); + }); + + it('should log an error when using the directive on more than one element', () => { + const {directive, element$} = createStoreDirective(); + const values: (HTMLElement | null)[] = []; + const unsubscribe = element$.subscribe((value) => values.push(value)); + expect(values).toEqual([null]); + const instance1 = directive(element); + expect(values).toEqual([null, element]); + const element2 = document.createElement('span'); + const instance2 = directive(element2); + expect(values).toEqual([null, element]); + expect(consoleErrorSpy).toHaveBeenCalledOnce(); + expect(consoleErrorSpy.mock.calls[0][0]).toBe('The directive cannot be used on multiple elements.'); + instance1?.destroy?.(); + expect(values).toEqual([null, element, null]); + instance2?.destroy?.(); + expect(values).toEqual([null, element, null]); + unsubscribe(); + consoleErrorSpy.mockClear(); + }); + }); + + describe('mergeDirectives', () => { + it('Basic functionalities', () => { + const directive1 = vitest.fn((element, value: number) => ({destroy: vitest.fn(), update: vitest.fn()})); + const directive2 = vitest.fn((element, value: number) => ({destroy: vitest.fn(), update: vitest.fn()})); + const mergedDirective = mergeDirectives(directive1, directive2); + expect(directive1).not.toHaveBeenCalled(); + expect(directive2).not.toHaveBeenCalled(); + const mergedDirectiveInstance = mergedDirective(element, 1); + expect(directive1).toHaveBeenCalledOnce(); + expect(directive2).toHaveBeenCalledOnce(); + const directive1Instance = directive1.mock.results[0].value; + const directive2Instance = directive2.mock.results[0].value; + expect(directive1Instance.update).not.toHaveBeenCalled(); + expect(directive2Instance.update).not.toHaveBeenCalled(); + mergedDirectiveInstance?.update?.(2); + expect(directive1Instance.update).toHaveBeenCalledOnce(); + expect(directive1Instance.update).toHaveBeenCalledWith(2); + expect(directive2Instance.update).toHaveBeenCalledOnce(); + expect(directive2Instance.update).toHaveBeenCalledWith(2); + expect(directive1Instance.destroy).not.toHaveBeenCalled(); + expect(directive2Instance.destroy).not.toHaveBeenCalled(); + mergedDirectiveInstance?.destroy?.(); + expect(directive1Instance.destroy).toHaveBeenCalledOnce(); + expect(directive2Instance.destroy).toHaveBeenCalledOnce(); + }); + + it('should wrap calls to directives in batch', () => { + const storeAndDirective = () => { + const store = writable(0); + const directive = (element: HTMLElement, value: number) => { + store.set(value); + return { + update(value: number) { + store.set(value); + }, + destroy() { + store.set(-1); + }, + }; + }; + return {store, directive}; + }; + const {store: store1, directive: directive1} = storeAndDirective(); + const {store: store2, directive: directive2} = storeAndDirective(); + const {store: store3, directive: directive3} = storeAndDirective(); + const mergedDirective = mergeDirectives(directive1, directive2, directive3); + const combineStores = computed(() => `${store1()},${store2()},${store3()}`); + const values: string[] = []; + const unsubscribe = combineStores.subscribe((value) => values.push(value)); + expect(values).toEqual(['0,0,0']); + const directiveInstance = mergedDirective(element, 2); + expect(values).toEqual(['0,0,0', '2,2,2']); + directiveInstance?.update?.(1); + expect(values).toEqual(['0,0,0', '2,2,2', '1,1,1']); + directiveInstance?.destroy?.(); + expect(values).toEqual(['0,0,0', '2,2,2', '1,1,1', '-1,-1,-1']); + unsubscribe(); + }); + }); +}); diff --git a/core/lib/services/directiveUtils.ts b/core/lib/services/directiveUtils.ts new file mode 100644 index 0000000000..e8aec28f9e --- /dev/null +++ b/core/lib/services/directiveUtils.ts @@ -0,0 +1,207 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import {asReadable, batch, readable, writable} from '@amadeus-it-group/tansu'; +import type {Directive} from '../types'; +import {noop} from '../utils'; + +/** + * Binds the given directive to a store that provides its argument. + * + * @remarks + * + * The returned directive can be used without argument, it will ignore any argument passed to it + * and will call the provided directive with the content of the provided store as its argument, + * calling its update method when the content of the store changes. + * + * @param directive - directive to bind + * @param directiveArg$ - store containing the argument of the directive + * @returns The bound directive that can be used with no argument. + */ +export const bindDirective = (directive: Directive, directiveArg$: ReadableSignal): Directive => { + return (element) => { + let firstTime = true; + let instance: ReturnType> | undefined; + const unsubscribe = directiveArg$.subscribe((value) => { + if (firstTime) { + firstTime = false; + instance = directive(element, value); + } else { + instance?.update?.(value); + } + }); + return { + destroy() { + instance?.destroy?.(); + unsubscribe(); + }, + }; + }; +}; + +const noArg = readable(undefined); + +/** + * Returns a directive that ignores any argument passed to it and calls the provided directive without any + * argument. + * + * @param directive - directive to wrap + * @returns The resulting directive. + */ +export const bindDirectiveNoArg = (directive: Directive) => bindDirective(directive, noArg); + +/** + * Returns a directive that subscribes to the given store while it is used on a DOM element, + * and that unsubscribes from it when it is no longer used. + * + * @param store - store on which there will be an active subscription while the returned directive is used. + * @param asyncUnsubscribe - true if unsubscribing from the store should be done asynchronously (which is the default), and + * false if it should be done synchronously when the directive is destroyed + * @returns The resulting directive. + */ +export const directiveSubscribe = + (store: ReadableSignal, asyncUnsubscribe = true): Directive => + () => { + const unsubscribe = store.subscribe(noop); + return { + destroy: async () => { + if (asyncUnsubscribe) { + await 0; + } + unsubscribe(); + }, + }; + }; + +/** + * Returns a directive that calls the provided function with the arguments passed to the directive + * on initialization and each time they are updated. + * + * @param update - Function called with the directive argument when the directive is initialized and when its argument is updated. + * @returns The resulting directive. + */ +export const directiveUpdate = + (update: (arg: T) => void): Directive => + (element, arg) => { + update(arg); + return { + update, + }; + }; + +const equalOption = {equal: Object.is}; + +/** + * Utility to create a store that contains an array of items. + * @returns a store containing an array of items. + */ +export const registrationArray = (): ReadableSignal & {register: (element: T) => () => void} => { + const elements$ = writable([] as T[], equalOption); + return asReadable(elements$, { + /** + * Add the given element to the array. + * @param element - Element to be added to the array. + * @returns A function to remove the element from the array. + */ + register: (element: T) => { + let removed = false; + elements$.update((currentElements) => [...currentElements, element]); + return () => { + if (!removed) { + removed = true; + elements$.update((currentElements) => { + const index = currentElements.indexOf(element); + if (index > -1) { + const copy = [...currentElements]; + copy.splice(index, 1); + return copy; + } + return currentElements; // no change + }); + } + }; + }, + }); +}; + +/** + * Returns a directive and a store. The store contains at any time the array of all the DOM elements on which the directive is + * currently used. + * + * @remarks + * If the directive is intended to be used on a single element element, it may be more appropriate to use + * {@link createStoreDirective} instead. + * + * @returns An object with two properties: the `directive` property that is the directive to use on some DOM elements, + * and the `elements$` property that is the store containing an array of all the elements on which the directive is currently + * used. + */ +export const createStoreArrayDirective = (): {directive: Directive; elements$: ReadableSignal} => { + const elements$ = registrationArray(); + return { + elements$: asReadable(elements$), + directive: (element) => ({destroy: elements$.register(element)}), + }; +}; + +/** + * Returns a directive and a store. When the directive is used on a DOM element, the store contains that DOM element. + * When the directive is not used, the store contains null. + * + * @remarks + * If the directive is used on more than one element, an error is displayed in the console and the element is ignored. + * If the directive is intended to be used on more than one element, please use {@link createStoreArrayDirective} instead. + * + * @returns An object with two properties: the `directive` property that is the directive to use on one DOM element, + * and the `element$` property that is the store containing the element on which the directive is currently used (or null + * if the store is not currently used). + */ +export const createStoreDirective = (): {directive: Directive; element$: ReadableSignal} => { + const element$ = writable(null as HTMLElement | null, equalOption); + return { + element$: asReadable(element$), + directive: (element) => { + let valid = false; + element$.update((currentElement) => { + if (currentElement) { + console.error('The directive cannot be used on multiple elements.', currentElement, element); + return currentElement; + } + valid = true; + return element; + }); + return valid + ? { + destroy() { + element$.update((currentElement) => (element === currentElement ? null : currentElement)); + }, + } + : undefined; + }, + }; +}; + +/** + * Merges multiple directives into a single directive that executes all of them when called. + * + * @remarks + * All directives receive the same argument upon initialization and update. + * Directives are created and updated in the same order as they appear in the arguments list, + * they are destroyed in the reverse order. + * All calls to the directives (to create, update and destroy them) are wrapped in a call to the + * batch function of tansu + * + * @param args - directives to merge into a single directive. + * @returns The resulting merged directive. + */ +export const mergeDirectives = + (...args: (Directive | Directive)[]): Directive => + (element, arg) => { + const instances = batch(() => args.map((directive) => directive(element, arg as any))); + return { + update(arg) { + batch(() => instances.forEach((instance) => instance?.update?.(arg as any))); + }, + destroy() { + batch(() => instances.reverse().forEach((instance) => instance?.destroy?.())); + }, + }; + }; diff --git a/core/lib/services/focustrack.spec.ts b/core/lib/services/focustrack.spec.ts new file mode 100644 index 0000000000..0057ad908a --- /dev/null +++ b/core/lib/services/focustrack.spec.ts @@ -0,0 +1,98 @@ +import {describe, test, expect, vi} from 'vitest'; +import {activeElement$, createHasFocus} from './focustrack'; + +describe(`Focustrack service`, () => { + describe('activeElement', () => { + test(`Basic functionalities`, () => { + const documentElement = document.documentElement; + const addEventListenerSpy = vi.spyOn(documentElement, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(documentElement, 'removeEventListener'); + + let activeElement: Element | null = null; + document.body.innerHTML = `
    `; + const unsubscribe = activeElement$.subscribe((el) => { + activeElement = el; + }); + expect(addEventListenerSpy).toHaveBeenCalledTimes(2); + expect(addEventListenerSpy).toHaveBeenCalledWith('focusin', expect.anything()); + expect(addEventListenerSpy).toHaveBeenCalledWith('focusout', expect.anything()); + + expect(activeElement).toBe(document.body); + const element = document.getElementById('id')!; + element.focus(); + expect(activeElement).toBe(element); + + unsubscribe(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('focusin', expect.anything()); + expect(removeEventListenerSpy).toHaveBeenCalledWith('focusout', expect.anything()); + expect(removeEventListenerSpy).toHaveBeenCalledTimes(2); + + vi.clearAllMocks(); + }); + }); + + describe('hasFocus', () => { + test(`Basic functionalities`, () => { + document.body.innerHTML = ` +
    + + +
    +
    + +
    +
    + +
    + `; + + const container1 = document.getElementById('container1')!; + const container2 = document.getElementById('container2')!; + const container3 = document.getElementById('container3')!; + + const input11 = document.getElementById('id1-1')!; + const input12 = document.getElementById('id1-2')!; + const input2 = document.getElementById('id2')!; + const input3 = document.getElementById('id3')!; + + const {directive, hasFocus$} = createHasFocus(); + + let hasFocus: boolean | null = null; + const unsubscribe = hasFocus$.subscribe((_hasFocus) => { + hasFocus = _hasFocus; + }); + expect(hasFocus).toBe(false); + + input11.focus(); + expect(hasFocus).toBe(false); + + let container1Directive = directive(container1); + expect(hasFocus, 'works with a single container').toBe(true); + + const container2Directive = directive(container2); + expect(hasFocus, 'works with an array of container').toBe(true); + + input12.focus(); + expect(hasFocus, 'internal change').toBe(true); + + input2.focus(); + expect(hasFocus, 'focus change from container1 to container2').toBe(true); + + input3.focus(); + expect(hasFocus, 'focus change outside').toBe(false); + + container1Directive?.destroy?.(); + container2Directive?.destroy?.(); + const container3Directive = directive(container3); + expect(hasFocus, 'patch container to the one containg the activeElement').toBe(true); + + container3Directive?.destroy?.(); + container1Directive = directive(container1); + container1.focus(); + expect(hasFocus, 'focus on the container').toBe(true); + + container1Directive?.destroy?.(); + unsubscribe(); + }); + }); +}); diff --git a/core/lib/services/focustrack.ts b/core/lib/services/focustrack.ts new file mode 100644 index 0000000000..15c51e271c --- /dev/null +++ b/core/lib/services/focustrack.ts @@ -0,0 +1,65 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; +import {computed, readable} from '@amadeus-it-group/tansu'; +import type {Directive} from '../types'; +import {createStoreArrayDirective} from './directiveUtils'; + +const evtFocusIn = 'focusin'; +const evtFocusOut = 'focusout'; + +export const activeElement$ = readable(null, { + onUse({set}) { + function setActiveElement() { + set(document.activeElement); + } + setActiveElement(); + + const container = document.documentElement; + function onFocusOut() { + setTimeout(setActiveElement); + } + + container.addEventListener(evtFocusIn, setActiveElement); + container.addEventListener(evtFocusOut, onFocusOut); + + return () => { + container.removeEventListener(evtFocusIn, setActiveElement); + container.removeEventListener(evtFocusOut, onFocusOut); + }; + }, + equal: Object.is, +}); + +export interface HasFocus { + /** + * Directive to put on some elements. + */ + directive: Directive; + + /** + * Store that contains true if the activeElement is one of the elements which has the directive, + * or any of their descendants. + */ + hasFocus$: ReadableSignal; +} + +export function createHasFocus(): HasFocus { + const {elements$, directive} = createStoreArrayDirective(); + + const hasFocus$ = computed(() => { + const activeElement = activeElement$(); + if (!activeElement) { + return false; + } + for (const element of elements$()) { + if (element === activeElement || element.contains(activeElement)) { + return true; + } + } + return false; + }); + + return { + directive, + hasFocus$, + }; +} diff --git a/core/lib/services/index.ts b/core/lib/services/index.ts new file mode 100644 index 0000000000..a8aeffd351 --- /dev/null +++ b/core/lib/services/index.ts @@ -0,0 +1,6 @@ +export * from './siblingsInert'; +export * from './directiveUtils'; +export * from './focustrack'; +export * from './portal'; +export * from './stores'; +export * from './writables'; diff --git a/core/lib/services/portal.spec.ts b/core/lib/services/portal.spec.ts new file mode 100644 index 0000000000..6f213d392c --- /dev/null +++ b/core/lib/services/portal.spec.ts @@ -0,0 +1,162 @@ +import {beforeEach, describe, expect, test} from 'vitest'; +import {portal} from './portal'; + +describe(`Portal`, () => { + let testArea: HTMLElement; + + beforeEach(() => { + testArea = document.body.appendChild(document.createElement('div')); + return () => { + testArea.parentElement?.removeChild(testArea); + }; + }); + + test('should move the element when changing container', () => { + const initialMarkup = ` +
    +
    +
    +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const element = document.getElementById('element')!; + const parentElement = document.getElementById('parentElement')!; + const beforeElement = document.getElementById('beforeElement')!; + const afterElement = document.getElementById('afterElement')!; + const containerElement = document.getElementById('containerElement')!; + const otherContainerElement = document.getElementById('otherContainerElement')!; + expect(element.parentElement).toBe(parentElement); + const instance = portal(element, {container: containerElement}); + expect(element.parentElement).toBe(containerElement); + instance?.update?.({container: otherContainerElement}); + expect(element.parentElement).toBe(otherContainerElement); + instance?.update?.(null); + expect(testArea.innerHTML).toBe(initialMarkup); + expect(element.parentElement).toBe(parentElement); + expect(element.previousElementSibling).toBe(beforeElement); + expect(element.nextElementSibling).toBe(afterElement); + instance?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup.replace('
    ', '')); + }); + + test('should not keep anything when destroying the item', () => { + const initialMarkup = ` +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const element = document.getElementById('element')!; + const parentElement = document.getElementById('parentElement')!; + const containerElement = document.getElementById('containerElement')!; + expect(element.parentElement).toBe(parentElement); + const instance = portal(element, {container: containerElement}); + expect(element.parentElement).toBe(containerElement); + instance?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup.replace('
    ', '')); + }); + + test('should keep the order when coming back to the initial container', () => { + const initialMarkup = ` +
    +
    +
    +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const element1 = document.getElementById('element1')!; + const element2 = document.getElementById('element2')!; + const parentElement = document.getElementById('parentElement')!; + const containerElement = document.getElementById('containerElement')!; + expect(element1.parentElement).toBe(parentElement); + expect(element2.parentElement).toBe(parentElement); + const instance1 = portal(element1, {container: containerElement}); + expect(element1.parentElement).toBe(containerElement); + const instance2 = portal(element2, {container: containerElement}); + expect(element2.parentElement).toBe(containerElement); + instance2?.update?.(null); + expect(element2.parentElement).toBe(parentElement); + instance1?.update?.({}); + expect(element1.parentElement).toBe(parentElement); + expect(testArea.innerHTML).toBe(initialMarkup); + instance1?.destroy?.(); + instance2?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup.replace('
    ', '').replace('
    ', '')); + }); + + test(`should use the precise position when given (both container and insertBefore)`, () => { + const initialMarkup = ` +
    +
    +
    +
    +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const element = document.getElementById('element')!; + const parentElement = document.getElementById('parentElement')!; + const containerSubElement1 = document.getElementById('containerSubElement1')!; + const containerSubElement2 = document.getElementById('containerSubElement2')!; + expect(element.parentElement).toBe(parentElement); + const portalInstance = portal(element, {container: containerSubElement2.parentElement!, insertBefore: containerSubElement2}); + expect(containerSubElement1.nextElementSibling).toBe(element); + expect(element.nextElementSibling).toBe(containerSubElement2); + portalInstance?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup.replace('
    ', '')); + }); + + test(`should raise an error when container does not contain the insertBefore node`, () => { + const initialMarkup = ` +
    +
    +
    +
    +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const element = document.getElementById('element')!; + const parentElement = document.getElementById('parentElement')!; + const containerSubElement2 = document.getElementById('containerSubElement2')!; + expect(element.parentElement).toBe(parentElement); + expect(() => { + portal(element, {container: parentElement, insertBefore: containerSubElement2}); + }).toThrow('not a child'); + }); + + test(`should use the precise position when given (only insertBefore)`, () => { + const initialMarkup = ` +
    +
    +
    +
    +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const element = document.getElementById('element')!; + const parentElement = document.getElementById('parentElement')!; + const containerSubElement1 = document.getElementById('containerSubElement1')!; + const containerSubElement2 = document.getElementById('containerSubElement2')!; + expect(element.parentElement).toBe(parentElement); + const portalInstance = portal(element, {insertBefore: containerSubElement2}); + expect(containerSubElement1.nextElementSibling).toBe(element); + expect(element.nextElementSibling).toBe(containerSubElement2); + portalInstance?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup.replace('
    ', '')); + }); +}); diff --git a/core/lib/services/portal.ts b/core/lib/services/portal.ts new file mode 100644 index 0000000000..c61bd1165e --- /dev/null +++ b/core/lib/services/portal.ts @@ -0,0 +1,46 @@ +import type {Directive} from '../types'; + +export type PortalDirectiveArg = + | { + container?: HTMLElement | null | undefined; + insertBefore?: HTMLElement | null | undefined; + } + | null + | undefined; + +export const portal: Directive = (content, newArg) => { + let arg: PortalDirectiveArg; + let replaceComment: Comment | null | undefined; + + const removeReplaceComment = () => { + if (replaceComment) { + replaceComment.parentNode?.replaceChild(content, replaceComment); + replaceComment = null; + } + }; + + const update = (newArg: PortalDirectiveArg) => { + if (newArg !== arg) { + arg = newArg; + const container = arg?.container ?? arg?.insertBefore?.parentElement; + if (container) { + if (!replaceComment) { + replaceComment = content.parentNode?.insertBefore(content.ownerDocument.createComment('portal'), content); + } + container.insertBefore(content, arg?.insertBefore ?? null); + } else { + removeReplaceComment(); + } + } + }; + + update(newArg); + + return { + update, + destroy: () => { + removeReplaceComment(); + content.parentNode?.removeChild(content); + }, + }; +}; diff --git a/core/lib/services/siblingsInert.spec.ts b/core/lib/services/siblingsInert.spec.ts new file mode 100644 index 0000000000..61c5cbb1bc --- /dev/null +++ b/core/lib/services/siblingsInert.spec.ts @@ -0,0 +1,190 @@ +import {beforeEach, describe, expect, test} from 'vitest'; +import {sliblingsInert} from './siblingsInert'; + +describe('sliblingsInert', () => { + let testArea: HTMLElement; + + beforeEach(() => { + testArea = document.body.appendChild(document.createElement('div')); + return () => { + testArea.parentElement?.removeChild(testArea); + }; + }); + + test('simple usage', () => { + const initialMarkup = ` +
    +
    +
    +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const instance = sliblingsInert(document.getElementById('element')!); + expect(testArea.innerHTML).toBe(` +
    +
    +
    +
    +
    +
    +
    + `); + instance?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup); + }); + + test('usage on two items, second destroyed first', () => { + const initialMarkup = ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const instance1 = sliblingsInert(document.getElementById('element1')!); + const only1Markup = ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `; + expect(testArea.innerHTML).toBe(only1Markup); + const instance2 = sliblingsInert(document.getElementById('element2')!); + expect(testArea.innerHTML).toBe(` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `); + instance2?.destroy?.(); + expect(testArea.innerHTML).toBe(only1Markup); + instance1?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup); + }); + + test('usage on two items, first destroyed first', () => { + const initialMarkup = ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const instance1 = sliblingsInert(document.getElementById('element1')!); + expect(testArea.innerHTML).toBe(` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `); + const instance2 = sliblingsInert(document.getElementById('element2')!); + const markup2 = ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `; + expect(testArea.innerHTML).toBe(markup2); + instance1?.destroy?.(); + expect(testArea.innerHTML).toBe(markup2); + instance2?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup); + }); + + test('case with 3 items', () => { + const initialMarkup = ` +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const instance1 = sliblingsInert(document.getElementById('element1')!); + expect(testArea.innerHTML).toBe(` +
    +
    +
    + `); + const instance2 = sliblingsInert(document.getElementById('element2')!); + const markup2 = ` +
    +
    +
    + `; + expect(testArea.innerHTML).toBe(markup2); + instance1?.destroy?.(); + expect(testArea.innerHTML).toBe(markup2); + instance2?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup); + }); + + test('case with 3 items, one already having inert', () => { + const initialMarkup = ` +
    +
    +
    + `; + testArea.innerHTML = initialMarkup; + const instance1 = sliblingsInert(document.getElementById('element1')!); + expect(testArea.innerHTML).toBe(` +
    +
    +
    + `); + const instance2 = sliblingsInert(document.getElementById('element2')!); + const markup2 = ` +
    +
    +
    + `; + expect(testArea.innerHTML).toBe(markup2); + instance1?.destroy?.(); + expect(testArea.innerHTML).toBe(markup2); + instance2?.destroy?.(); + expect(testArea.innerHTML).toBe(initialMarkup); + }); +}); diff --git a/core/lib/services/siblingsInert.ts b/core/lib/services/siblingsInert.ts new file mode 100644 index 0000000000..9fc03b8652 --- /dev/null +++ b/core/lib/services/siblingsInert.ts @@ -0,0 +1,54 @@ +import {computed} from '@amadeus-it-group/tansu'; +import {noop} from '../utils'; +import {createStoreArrayDirective, directiveSubscribe, mergeDirectives} from './directiveUtils'; + +const internalSetSiblingsInert = (element: Element) => { + const inertValues = new Map(); + + const recursiveHelper = (element: Element) => { + const parent = element.parentElement; + if (parent && element !== document.body) { + Array.from(parent.children).forEach((sibling) => { + if (sibling !== element && sibling.nodeName !== 'SCRIPT') { + inertValues.set(sibling, sibling.hasAttribute('inert')); + sibling.toggleAttribute('inert', true); + } + }); + + recursiveHelper(parent); + } + }; + + recursiveHelper(element); + + return () => + inertValues.forEach((value, element) => { + element.toggleAttribute('inert', value); + }); +}; + +let internalRevert = noop; + +const setSiblingsInert = (element: HTMLElement | null | undefined) => { + internalRevert(); + internalRevert = element ? internalSetSiblingsInert(element) : noop; +}; + +const {directive: storeArrayDirective, elements$} = createStoreArrayDirective(); + +const lastElement$ = computed( + () => { + const elements = elements$(); + return elements[elements.length - 1]; + }, + {equal: Object.is} +); +const inertAction$ = computed(() => setSiblingsInert(lastElement$())); + +/** + * sliblingsInert directive + * When used on an element, all siblings of the element and of its ancestors will be inert with the inert attribute. + * In case it is used on multiple elements, only the last one has an effect (the directive keeps a stack of elements + * on which it is used, so when the last one disappears, the previous one in the list becomes the one in effect). + */ +export const sliblingsInert = mergeDirectives(storeArrayDirective, directiveSubscribe(inertAction$)); diff --git a/core/lib/services/stores.spec.ts b/core/lib/services/stores.spec.ts new file mode 100644 index 0000000000..813e4e9e56 --- /dev/null +++ b/core/lib/services/stores.spec.ts @@ -0,0 +1,311 @@ +import type {WritableSignal} from '@amadeus-it-group/tansu'; +import {writable} from '@amadeus-it-group/tansu'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; +import {bindableDerived, createPatch, findChangedProperties, INVALID_VALUE, stateStores, writablesWithDefault, writableWithDefault} from './stores'; + +describe(`Stores service`, () => { + describe('createPatch', () => { + let a$: WritableSignal | undefined; + let b$: WritableSignal | undefined; + + function getInitialState() { + return { + a: [], + b: [], + }; + } + + let state: ReturnType; + + beforeEach(() => { + a$ = writable(1); + b$ = writable(1); + state = getInitialState(); + + const unsubscribeA = a$.subscribe((value) => { + state.a.push(value); + }); + + const unsubscribeB = b$.subscribe((value) => { + state.b.push(value); + }); + + return () => { + a$ = b$ = undefined; + unsubscribeA(); + unsubscribeB(); + }; + }); + + test(`Basic functionalities`, () => { + const patch = createPatch({a$, b$}); + + const expectedState = { + a: [1], + b: [1], + }; + + expect(state).toEqual(expectedState); + + patch({a: 2}); + + expectedState.a.push(2); + expect(state, 'Partial change').toEqual(expectedState); + + patch({a: 3, b: 3}); + + expectedState.a.push(3); + expectedState.b.push(3); + expect(state, 'Grouped change').toEqual(expectedState); + + patch({a: 4, c: 4}); + + expectedState.a.push(4); + expect(state, 'Change with unknown key').toEqual(expectedState); + }); + }); + + describe('findChangedProperties', () => { + test(`Basic functionalities`, () => { + const jsonRef: Partial<{a: number; b: number; c: number}> = {a: 1, b: 1}; + expect(findChangedProperties(jsonRef, {a: 1, b: 1})).toBeNull(); + expect(findChangedProperties(jsonRef, {a: 1, c: 1})).toEqual({c: 1}); + expect(findChangedProperties(jsonRef, {a: 2})).toEqual({a: 2, b: undefined}); + expect(findChangedProperties(jsonRef, {a: 2, b: 1})).toEqual({a: 2}); + expect(findChangedProperties(jsonRef, {a: 2, c: 1})).toEqual({a: 2, c: 1}); + }); + }); + + describe('writableWithDefault', () => { + test(`Basic functionalities`, () => { + const values: number[] = []; + const config$ = writable(undefined as undefined | number); + const store$ = writableWithDefault(0, config$); + store$.subscribe((a) => values.push(a)); + expect(values).toEqual([0]); + config$.set(1); + expect(values).toEqual([0, 1]); + config$.set(undefined); + expect(values).toEqual([0, 1, 0]); + config$.set(3); + expect(values).toEqual([0, 1, 0, 3]); + store$.set(4); + expect(values).toEqual([0, 1, 0, 3, 4]); + config$.set(5); + expect(values).toEqual([0, 1, 0, 3, 4]); + config$.set(6); + expect(values).toEqual([0, 1, 0, 3, 4]); + store$.set(undefined); + expect(values).toEqual([0, 1, 0, 3, 4, 6]); + }); + + test(`Invalid values`, () => { + const spyErrorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + const values: number[] = []; + const config$ = writable(undefined as undefined | number); + const store$ = writableWithDefault(0, config$, { + normalizeValue(value) { + if (value < 0) { + // negative values are invalid + return INVALID_VALUE; + } + return value; + }, + }); + store$.subscribe((a) => values.push(a)); + expect(values).toEqual([0]); + expect(spyErrorLog).not.toBeCalled(); + store$.set(-1); // invalid value is ignored + expect(spyErrorLog).toBeCalledTimes(1); + expect(spyErrorLog.mock.calls[0][0]).to.match(/invalid value/i); + spyErrorLog.mockReset(); + expect(values).toEqual([0]); + expect(spyErrorLog).not.toBeCalled(); + config$.set(-1); // invalid value is ignored + expect(spyErrorLog).toBeCalledTimes(1); + expect(spyErrorLog.mock.calls[0][0]).to.match(/invalid value/i); + spyErrorLog.mockReset(); + expect(values).toEqual([0]); + config$.set(1); // correct value + expect(values).toEqual([0, 1]); + }); + }); + + describe('writablesWithDefault', () => { + test(`Basic functionalities with a store`, () => { + const defConfig = {a: 1, b: 2}; + const config$ = writable>({}); + const props = writablesWithDefault(defConfig, config$); + const a: number[] = []; + const b: number[] = []; + const unsubscribeA = props.a$.subscribe((value) => a.push(value)); + const unsubscribeB = props.b$.subscribe((value) => b.push(value)); + expect(a).toEqual([1]); + expect(b).toEqual([2]); + config$.set({a: 2, b: 3}); + expect(a).toEqual([1, 2]); + expect(b).toEqual([2, 3]); + config$.set({b: undefined}); + expect(a).toEqual([1, 2, 1]); + expect(b).toEqual([2, 3, 2]); + props.a$.set(5); + expect(a).toEqual([1, 2, 1, 5]); + config$.set({a: 6}); + expect(a).toEqual([1, 2, 1, 5]); + props.a$.set(undefined); + expect(a).toEqual([1, 2, 1, 5, 6]); + unsubscribeA(); + unsubscribeB(); + }); + + test(`Basic functionalities with an object containing a store and a value`, () => { + const defConfig = {a: 1, b: 2}; + const config = {a: writable(4 as number | undefined), b: 3}; + const props = writablesWithDefault(defConfig, config); + const a: number[] = []; + const b: number[] = []; + const unsubscribeA = props.a$.subscribe((value) => a.push(value)); + const unsubscribeB = props.b$.subscribe((value) => b.push(value)); + expect(a).toEqual([4]); + expect(b).toEqual([3]); + config.a.set(5); + expect(a).toEqual([4, 5]); + config.a.set(undefined); + expect(a).toEqual([4, 5, 1]); + props.a$.set(7); + expect(a).toEqual([4, 5, 1, 7]); + config.a.set(6); + expect(a).toEqual([4, 5, 1, 7]); + props.a$.set(undefined); + expect(a).toEqual([4, 5, 1, 7, 6]); + expect(b).toEqual([3]); + props.b$.set(8); + expect(b).toEqual([3, 8]); + props.b$.set(undefined); + expect(b).toEqual([3, 8, 3]); + unsubscribeA(); + unsubscribeB(); + }); + + test(`Invalid values`, () => { + const spyErrorLog = vi.spyOn(console, 'error'); + const defConfig = {a: 1, b: 2}; + const config$ = writable>({}); + const props = writablesWithDefault(defConfig, config$, { + a: { + normalizeValue(value) { + if (value < 0) { + // negative values are invalid + return INVALID_VALUE; + } + return value; + }, + }, + }); + const a: number[] = []; + props.a$.subscribe((value) => a.push(value)); + expect(a).toEqual([1]); + expect(spyErrorLog).not.toBeCalled(); + config$.set({a: -1}); // invalid value is ignored + expect(spyErrorLog).toBeCalledTimes(1); + expect(spyErrorLog.mock.calls[0][0]).to.match(/invalid value/i); + spyErrorLog.mockReset(); + expect(spyErrorLog).not.toBeCalled(); + props.a$.set(-2); // invalid value is ignored + expect(spyErrorLog).toBeCalledTimes(1); + expect(spyErrorLog.mock.calls[0][0]).to.match(/invalid value/i); + spyErrorLog.mockReset(); + config$.set({a: 3}); + expect(a).toEqual([1, 3]); + props.a$.set(4); + expect(a).toEqual([1, 3, 4]); + }); + }); + + describe('stateStores', () => { + test('Basic functionalities', () => { + const stores = {a$: writable(5), b$: writable(6)}; + const res = stateStores(stores); + const stateValues: {a: number; b: number}[] = []; + expect((res.stores.a$ as any).set).toBeUndefined(); + expect((res.stores.a$ as any).update).toBeUndefined(); + expect(res.stores.a$()).toBe(5); + expect(res.stores.b$()).toBe(6); + const unsubscribeState = res.state$.subscribe((value) => stateValues.push(value)); + expect(stateValues).toEqual([{a: 5, b: 6}]); + stores.a$.set(9); + expect(stateValues.length).toBe(2); + expect(stateValues[1]).toEqual({a: 9, b: 6}); + expect(res.stores.a$()).toBe(9); + unsubscribeState(); + }); + }); + + describe('bindableDerived', () => { + test('Basic functionalities', () => { + const onChangeCalls: number[] = []; + const values: number[] = []; + const dirtyValue$ = writable(1); + const onValueChange$ = writable((value: number) => { + onChangeCalls.push(value); + }); + const valueMax$ = writable(2); + + const value$ = bindableDerived(onValueChange$, [dirtyValue$, valueMax$], ([dirtyValue, valueMax]) => Math.min(dirtyValue, valueMax)); + const unsubscribe = value$.subscribe((value) => values.push(value)); + expect(values).toEqual([1]); + valueMax$.set(3); // no change + expect(onChangeCalls).toEqual([]); + expect(values).toEqual([1]); + + dirtyValue$.set(2); + expect(onChangeCalls).toEqual([2]); + expect(values).toEqual([1, 2]); + + valueMax$.set(4); // no change + expect(onChangeCalls).toEqual([2]); + + dirtyValue$.set(5); + expect(onChangeCalls).toEqual([2, 4]); + expect(values).toEqual([1, 2, 4]); + + valueMax$.set(3); + expect(onChangeCalls).toEqual([2, 4, 3]); + expect(values).toEqual([1, 2, 4, 3]); + + const newListener = vi.fn((value) => { + // this should do nothing + valueMax$.set(5); + }); + onValueChange$.set(newListener); + dirtyValue$.set(2); + expect(onChangeCalls).toEqual([2, 4, 3]); // listener was changed, old listener not called + expect(newListener).toHaveBeenCalledWith(2); + expect(newListener).toHaveBeenCalledOnce(); + expect(values).toEqual([1, 2, 4, 3, 2]); + unsubscribe(); + }); + + test('Should always call the onChange listener when the value was adjusted', () => { + const onChangeCalls: number[] = []; + const values: number[] = []; + const dirtyValue$ = writable(1); + const onValueChange$ = writable((value: number) => { + onChangeCalls.push(value); + }); + const valueMax$ = writable(2); + + const value$ = bindableDerived(onValueChange$, [dirtyValue$, valueMax$], ([dirtyValue, valueMax]) => Math.min(dirtyValue, valueMax)); + const unsubscribe = value$.subscribe((value) => values.push(value)); + expect(onChangeCalls).toEqual([]); + expect(values).toEqual([1]); + dirtyValue$.set(3); + expect(onChangeCalls).toEqual([2]); + expect(values).toEqual([1, 2]); + dirtyValue$.set(3); + expect(onChangeCalls).toEqual([2, 2]); // no change compared to the last valid value, but the value was adjusted + expect(values).toEqual([1, 2]); + unsubscribe(); + }); + }); +}); diff --git a/core/lib/services/stores.ts b/core/lib/services/stores.ts new file mode 100644 index 0000000000..a0a9049778 --- /dev/null +++ b/core/lib/services/stores.ts @@ -0,0 +1,279 @@ +import {batch, computed, derived, get, readable, writable, asReadable} from '@amadeus-it-group/tansu'; +import type {ReadableSignal, Updater, WritableSignal, StoreOptions, StoreInput, StoresInputValues} from '@amadeus-it-group/tansu'; +import {identity} from '../utils'; + +export type ToWritableSignal = { + [K in keyof P & keyof V as `${K & string}$`]-?: WritableSignal; +}; + +export type ValuesOrStores = { + [K in keyof T]?: ReadableSignal | T[K]; +}; + +export type WithoutDollar = S extends `${infer U}$` ? U : never; +export type ValueOfStore> = S extends ReadableSignal ? U : never; +export type ToState}> = { + [K in keyof S & `${string}$` as WithoutDollar]: ValueOfStore; +}; + +/** + * + * Utility function designed to create a `patch` function related to the provided stores. + * Any key given to the patch function which is not in the original object will be ignored. + * + * @param stores - object of stores + * + * @example + * + * ```typescript + * const storeA$ = writable(1); + * const storeB$ = writable(1); + * const patch = createPatch({a: storeA$, b: storeB$}); + * + * patch({a: 2}) // will perform storeA$.set(2) + * patch({a: 2, b: 2}) // will perform storeA$.set(2) and storeB$.set(2) in the same batch. + * patch({a: 2, c: 2}) // will perform storeA$.set(2), c is ignored. + * + * ``` + * @returns + */ +export function createPatch(stores: ToWritableSignal) { + return function >(storesValues?: U | void) { + batch(() => { + for (const [name, value] of Object.entries(storesValues ?? {})) { + (stores as any)[`${name}$`]?.set(value); + } + }); + }; +} + +/** + * This utility function is designed to compare the first level of two objects. + * + * It returns a new object which has all the keys for which the values in `obj1` + * and `obj2` are different, with the values from `obj2`, or null if objects + * are identical. + * + * @param obj1 - First object + * @param obj2 - Second object + */ +export function findChangedProperties>(obj1: Partial, obj2: Partial): Partial | null { + if (obj1 === obj2) { + return null; + } + let hasUpdate = false; + const changedValues: Partial = {}; + const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]); + for (const key of keys) { + const value = obj2[key]; + if (obj1[key] !== value) { + changedValues[key] = value; + hasUpdate = true; + } + } + return hasUpdate ? changedValues : null; +} + +const update = function (this: WritableSignal, updater: Updater) { + this.set(updater(this())); +}; + +export const INVALID_VALUE = Symbol(); +export type NormalizeValue = (value: U) => T | typeof INVALID_VALUE; + +export interface WritableWithDefaultOptions { + normalizeValue?: NormalizeValue; + equal?: StoreOptions['equal']; +} + +/** + * Returns a writable store whose value is either its own value (when it is not undefined) or a default value + * that comes either from the `config$` store (when it is not undefined) or from `defValue`. + * If a normalizeValue function is passed in the options, it is called to normalize non-undefined values coming + * either from the `config$` store or from the `set` or `update` functions. If a value is invalid (i.e. normalizeValue + * returns the `invalidValue` symbol), an error is logged on the console and it is either not set (if it comes from the + * `set` or `update` functions), or the `defValue` is used instead (if the invalid value comes from the `config$` store). + * + * @param defValue - Default value used when both the own value and the config$ value are undefined. + * @param config$ - Default value used when the own value is undefined. + * @param options - Object which can contain the following optional functions: normalizeValue and equal + * @returns a writable store with the extra default value and normalization logic described above + */ +export function writableWithDefault( + defValue: T, + config$: ReadableSignal = readable(undefined), + {normalizeValue = identity as any, equal}: WritableWithDefaultOptions = {} +): WritableSignal { + const own$ = writable(undefined as T | undefined); + const validatedDefConfig$ = computed( + () => { + const value = config$(); + const normalizedValue = value === undefined ? undefined : normalizeValue(value); + if (normalizedValue === INVALID_VALUE) { + console.error('Not using invalid value from default config', value); + return defValue; + } + if (normalizedValue === undefined) { + return defValue; + } + return normalizedValue; + }, + {equal} + ); + return asReadable( + computed( + () => { + const ownValue = own$(); + return ownValue !== undefined ? ownValue : validatedDefConfig$(); + }, + {equal} + ), + { + set(value: U | undefined) { + const normalizedValue = value === undefined ? undefined : normalizeValue(value); + if (normalizedValue === INVALID_VALUE) { + console.error('Not setting invalid value', value); + } else { + own$.set(normalizedValue); + } + }, + update, + } + ); +} + +export type ConfigValidator = {[K in keyof T & keyof U]?: WritableWithDefaultOptions}; + +const isStore = (x: any): x is ReadableSignal => !!(x && typeof x === 'function' && 'subscribe' in x); + +/** + * Returns an object containing, for each property of `defConfig`, a corresponding writable with the normalization and default value logic + * described in {@link writableWithDefault}. Keys in the returned object are the same as the ones present in `defConfig`, + * with the exta `$` suffix (showing that they are stores). + * + * @param defConfig - object containing, for each property, a default value to use in case `config$` does not provide the suitable default + * value for that property + * @param config - either a store of objects containing, for each property of `defConfig`, the default value or an object containing + * for each property of `defConfig` either a store containing the default value or the default value itself + * @param options - object containing, for each property of `defConfig`, an optional object with the following optional functions: normalizeValue and equal + * @returns an object containing writables + * + * @example With a store + * ```ts + * const defConfig = {propA: 1}; + * const validation = {propA: {normalizeValue: value => +value}}; + * const config$ = writable({propA: 5}); + * const {propA$} = writablesWithDefault(defConfig, config$, validation); + * ``` + * + * @example With an object containing a value and a store + * ```ts + * const defConfig = {propA: 1, propB: 2}; + * const validation = {propA: {normalizeValue: value => +value}}; + * const config = {propA: 5, propB: writable(3)}; + * const {propA$, propB$} = writablesWithDefault(defConfig, config, validation); + * ``` + */ +export const writablesWithDefault = ( + defConfig: T, + config?: PropsConfig, + options?: ConfigValidator +): ToWritableSignal => { + const res: any = {}; + const configIsStore = isStore(config); + for (const key of Object.keys(defConfig) as (string & keyof T & keyof U)[]) { + let store: ReadableSignal | undefined = configIsStore ? computed(() => config()[key]) : undefined; + if (!configIsStore && config) { + const value = config[key]; + store = isStore(value) ? value : readable(value); + } + res[`${key}$`] = writableWithDefault(defConfig[key], store, options?.[key]); + } + return res as ToWritableSignal; +}; + +/** + * Shortcut for calling both {@link writablesWithDefault} and {@link createPatch} in one call. + * @param defConfig - object containing, for each property, a default value to use in case `config` does not provide the suitable default + * value for that property + * @param config - either a store of objects containing, for each property of `defConfig`, the default value or an object containing + * for each property of `defConfig` either a store containing the default value or the default value itself + * @param options - object containing, for each property of `defConfig`, an optional object with the following optional functions: normalizeValue and equal + * @returns an array with two items: the first one containing the writables (returned by {@link writablesWithDefault}), + * and the second one containing the patch function (returned by {@link createPatch}) + * + * @example With a store + * ```ts + * const defConfig = {propA: 1}; + * const validation = {propA: {normalizeValue: value => +value}}; + * const config$ = writable({propA: 5}); + * const [{propA$}, patch] = writablesForProps(defConfig, config$, validation); + * ``` + * + * @example With an object containing a value and a store + * ```ts + * const defConfig = {propA: 1, propB: 2}; + * const validation = {propA: {normalizeValue: value => +value}}; + * const config = {propA: 5, propB: writable(3)}; + * const [{propA$, propB$}, patch] = writablesForProps(defConfig, config, validation); + * ``` + */ +export const writablesForProps = ( + defConfig: T, + config?: PropsConfig, + options?: {[K in keyof T & keyof U]?: WritableWithDefaultOptions} +): [ToWritableSignal, ReturnType>] => { + const stores = writablesWithDefault(defConfig, config, options); + return [stores, createPatch(stores)]; +}; + +export type PropsConfig = ReadableSignal> | ValuesOrStores; + +export const stateStores = }>( + inputStores: A +): {state$: ReadableSignal>; stores: {[key in `${string}$` & keyof A]: ReadableSignal>}} => { + const storesNames: string[] = []; + const storesArray: any = []; + const stores: any = {}; + for (const key of Object.keys(inputStores) as (`${string}$` & keyof A)[]) { + if (key.endsWith('$')) { + const store = inputStores[key]; + storesNames.push(key.substring(0, key.length - 1)); + storesArray.push(store); + stores[key] = asReadable(store); + } + } + return { + stores, + state$: computed(() => { + const values = storesArray.map(get); + const res: any = {}; + storesNames.forEach((name, index) => { + res[name] = values[index]; + }); + return res; + }), + }; +}; + +export const bindableDerived = , ...StoreInput[]]>( + onChange$: ReadableSignal<(value: T) => void>, + stores: U, + adjustValue: (arg: StoresInputValues) => T +) => { + let currentValue = stores[0](); + return derived(stores, (values) => { + const newValue = adjustValue(values); + const rectifiedValue = newValue !== values[0]; + if (rectifiedValue) { + stores[0].set(newValue); + } + if (rectifiedValue || newValue !== currentValue) { + currentValue = newValue; + // TODO check if we should do this async to avoid issue + // with angular and react only when rectifiedValue is true? + onChange$()(newValue); + } + return newValue; + }); +}; diff --git a/core/lib/services/writables.ts b/core/lib/services/writables.ts new file mode 100644 index 0000000000..183e81455e --- /dev/null +++ b/core/lib/services/writables.ts @@ -0,0 +1,25 @@ +import {isBoolean, isFunction, isNumber, isString} from './checks'; +import type {WritableWithDefaultOptions} from './stores'; +import {INVALID_VALUE} from './stores'; + +export const testToNormalizeValue = + (filter: (value: T) => boolean) => + (value: T) => + filter(value) ? value : INVALID_VALUE; + +export const typeNumber: WritableWithDefaultOptions = { + normalizeValue: testToNormalizeValue(isNumber), +}; + +export const typeBoolean: WritableWithDefaultOptions = { + normalizeValue: testToNormalizeValue(isBoolean), +}; + +export const typeString: WritableWithDefaultOptions = { + normalizeValue: testToNormalizeValue(isString), +}; + +export const typeFunction: WritableWithDefaultOptions<(...args: any[]) => any> = { + normalizeValue: testToNormalizeValue(isFunction), + equal: Object.is, +}; diff --git a/core/lib/transitions/baseTransitions.spec.ts b/core/lib/transitions/baseTransitions.spec.ts new file mode 100644 index 0000000000..c0b43dc1fa --- /dev/null +++ b/core/lib/transitions/baseTransitions.spec.ts @@ -0,0 +1,642 @@ +import {expect, test, describe, beforeEach} from 'vitest'; +import {createTransition} from './baseTransitions'; +import type {TransitionFn} from './baseTransitions'; +import {promiseFromStore} from './utils'; +import {writable} from '@amadeus-it-group/tansu'; + +describe(`createTransition`, () => { + let events: string[] = []; + let transitionCalls = 0; + const onShown = () => events.push('onShown'); + const onHidden = () => events.push('onHidden'); + const onVisibleChange = (value: boolean) => events.push(`onVisibleChange:${value}`); + const transition: TransitionFn = async (element, direction, animation, signal, context: {callNumber?: number}) => { + transitionCalls++; + const callNumber = transitionCalls; + if (!context.callNumber) { + context.callNumber = callNumber; + } + events.push(`transitionStart:${callNumber}:${element.id}:${direction}:anim=${animation}:ctxt=${context.callNumber}`); + let aborted = false; + const abort = new Promise((resolve) => { + signal.addEventListener('abort', () => { + events.push(`transitionAbort:${callNumber}`); + aborted = true; + resolve(); + }); + }); + await Promise.race([abort, new Promise((resolve) => setTimeout(resolve, 50))]); + events.push(`transitionEnd:${callNumber}:aborted=${aborted}`); + }; + + beforeEach(() => { + transitionCalls = 0; + events = []; + }); + + test(`basic case`, async () => { + const element = {id: 'domEl'}; + const transitionInstance = createTransition({ + animationOnInit: true, + visible: true, + transition, + onShown, + onHidden, + onVisibleChange, + }); + const directiveInstance = transitionInstance.directives.directive(element); + await transitionInstance.api.show(); + events.push('here'); + await transitionInstance.api.hide(); + events.push('finished'); + directiveInstance?.destroy?.(); + expect(events).toEqual([ + 'transitionStart:1:domEl:show:anim=true:ctxt=1', + 'transitionEnd:1:aborted=false', + 'onShown', + 'here', + 'onVisibleChange:false', + 'transitionStart:2:domEl:hide:anim=true:ctxt=2', + 'transitionEnd:2:aborted=false', + 'onHidden', + 'finished', + ]); + }); + + test(`simultaneous calls show/hide`, async () => { + const element = {id: 'domEl'}; + const transitionInstance = createTransition({ + animationOnInit: true, + visible: true, + transition, + onShown, + onHidden, + onVisibleChange, + }); + const directiveInstance = transitionInstance.directives.directive(element); + const transition1 = transitionInstance.api.show(); + transition1.finally(() => { + throw new Error('transition1 is expected not to resolve'); + }); + events.push('afterRunTransition1'); + expect(transitionInstance.stores.transitioning$()).toBe(true); + const transition2 = transitionInstance.api.hide(); + events.push('afterRunTransition2'); + expect(transitionInstance.stores.transitioning$()).toBe(true); + await transition2; + events.push('afterAwaitRunTransition2'); + expect(transitionInstance.stores.transitioning$()).toBe(false); + directiveInstance?.destroy?.(); + expect(events).toEqual([ + 'transitionStart:1:domEl:show:anim=true:ctxt=1', + 'afterRunTransition1', + 'onVisibleChange:false', + 'transitionAbort:1', + 'transitionStart:2:domEl:hide:anim=true:ctxt=1', // the first context is reused + 'afterRunTransition2', + 'transitionEnd:1:aborted=true', + 'transitionEnd:2:aborted=false', + 'onHidden', + 'afterAwaitRunTransition2', + ]); + }); + + test(`simultaneous calls show/hide/show`, async () => { + const element = {id: 'domEl'}; + const transitionInstance = createTransition({ + animationOnInit: true, + visible: true, + transition, + onShown, + onHidden, + onVisibleChange, + }); + const directiveInstance = transitionInstance.directives.directive(element); + const transition1 = transitionInstance.api.show(); + transition1.finally(() => { + throw new Error('transition1 is expected not to resolve'); + }); + events.push('afterRunTransition1'); + await new Promise((resolve) => setTimeout(resolve, 2)); + events.push('afterTimeoutTransition1'); + const transition2 = transitionInstance.api.hide(); + transition2.finally(() => { + throw new Error('transition2 is expected not to resolve'); + }); + events.push('afterRunTransition2'); + await new Promise((resolve) => setTimeout(resolve, 2)); + events.push('afterTimeoutTransition2'); + const transition3 = transitionInstance.api.show(); + events.push('afterRunTransition3'); + await new Promise((resolve) => setTimeout(resolve, 2)); + events.push('afterTimeoutTransition3'); + await transition3; + events.push('afterAwaitRunTransition3'); + directiveInstance?.destroy?.(); + expect(events).toEqual([ + 'transitionStart:1:domEl:show:anim=true:ctxt=1', + 'afterRunTransition1', + 'afterTimeoutTransition1', + 'onVisibleChange:false', + 'transitionAbort:1', + 'transitionStart:2:domEl:hide:anim=true:ctxt=1', + 'afterRunTransition2', + 'transitionEnd:1:aborted=true', + 'afterTimeoutTransition2', + 'onVisibleChange:true', + 'transitionAbort:2', + 'transitionStart:3:domEl:show:anim=true:ctxt=1', + 'afterRunTransition3', + 'transitionEnd:2:aborted=true', + 'afterTimeoutTransition3', + 'transitionEnd:3:aborted=false', + 'onShown', + 'afterAwaitRunTransition3', + ]); + }); + + test(`simultaneous calls, show/show`, async () => { + const element = {id: 'domEl'}; + const transitionInstance = createTransition({ + animationOnInit: true, + visible: true, + onShown, + onHidden, + onVisibleChange, + transition, + }); + const directiveInstance = transitionInstance.directives.directive(element); + const transition1 = transitionInstance.api.show(); + events.push('afterRunTransition1'); + const transition2 = transitionInstance.api.show(); + transition1.finally(() => { + throw new Error('transition1 is expected not to resolve'); + }); + events.push('afterRunTransition2'); + await transition2; + events.push('afterAwaitRunTransition2'); + directiveInstance?.destroy?.(); + expect(events).toEqual([ + 'transitionStart:1:domEl:show:anim=true:ctxt=1', + 'afterRunTransition1', + 'afterRunTransition2', + 'transitionEnd:1:aborted=false', + 'onShown', + 'afterAwaitRunTransition2', + ]); + }); + + test(`changing visible to false before calling the directive`, async () => { + const element = {id: 'domEl'}; + let hiddenCalled: () => void; + const hiddenCalledPromise = new Promise((resolve) => (hiddenCalled = resolve)); + const transitionInstance = createTransition({ + onShown, + onHidden: function () { + hiddenCalled(); + onHidden(); + }, + onVisibleChange, + transition, + }); + events.push('afterCreateTransition'); + transitionInstance.patch({visible: false}); + events.push('afterPatch'); + const directiveInstance = transitionInstance.directives.directive(element); + events.push('afterDirective'); + await hiddenCalledPromise; + events.push('afterHidden'); + directiveInstance?.destroy?.(); + events.push('afterDestroy'); + expect(events).toEqual([ + 'afterCreateTransition', + 'afterPatch', + 'onVisibleChange:false', + 'transitionStart:1:domEl:hide:anim=false:ctxt=1', + 'afterDirective', + 'transitionEnd:1:aborted=false', + 'onHidden', + 'afterHidden', + 'afterDestroy', + ]); + }); + + test(`changing visible to true before calling the directive (with initDone = true)`, async () => { + const element = {id: 'domEl'}; + let shownCalled: () => void; + const shownCalledPromise = new Promise((resolve) => (shownCalled = resolve)); + const transitionInstance = createTransition({ + visible: false, + onHidden, + onVisibleChange, + onShown: function () { + shownCalled(); + onShown(); + }, + transition, + }); + events.push('afterCreateTransition'); + transitionInstance.patch({initDone: true, visible: true}); + events.push('afterPatch'); + const directiveInstance = transitionInstance.directives.directive(element); + events.push('afterDirective'); + await shownCalledPromise; + events.push('afterShown'); + directiveInstance?.destroy?.(); + events.push('afterDestroy'); + expect(events).toEqual([ + 'afterCreateTransition', + 'afterPatch', + 'onVisibleChange:true', + 'transitionStart:1:domEl:show:anim=true:ctxt=1', + 'afterDirective', + 'transitionEnd:1:aborted=false', + 'onShown', + 'afterShown', + 'afterDestroy', + ]); + }); + + test(`changing visible to true before calling the directive (with initDone = null)`, async () => { + const element = {id: 'domEl'}; + let shownCalled: () => void; + const shownCalledPromise = new Promise((resolve) => (shownCalled = resolve)); + const transitionInstance = createTransition({ + visible: false, + onHidden, + onVisibleChange, + onShown: function () { + shownCalled(); + onShown(); + }, + transition, + }); + events.push('afterCreateTransition'); + transitionInstance.patch({visible: true}); + events.push('afterPatch'); + const directiveInstance = transitionInstance.directives.directive(element); + events.push('afterDirective'); + await shownCalledPromise; + events.push('afterShown'); + directiveInstance?.destroy?.(); + events.push('afterDestroy'); + expect(events).toEqual([ + 'afterCreateTransition', + 'afterPatch', + 'onVisibleChange:true', + 'transitionStart:1:domEl:show:anim=false:ctxt=1', + 'afterDirective', + 'transitionEnd:1:aborted=false', + 'onShown', + 'afterShown', + 'afterDestroy', + ]); + }); + + test(`changing visible to true after calling the directive (with initDone = false)`, async () => { + const element = {id: 'domEl'}; + let shownCalled: () => void; + const initDone = writable(false); + const shownCalledPromise = new Promise((resolve) => (shownCalled = resolve)); + const transitionInstance = createTransition({ + visible: false, + initDone, + onHidden, + onVisibleChange, + onShown: function () { + shownCalled(); + onShown(); + }, + transition, + }); + events.push('afterCreateTransition'); + const directiveInstance = transitionInstance.directives.directive(element); // this does not change initDone + events.push('afterDirective'); + transitionInstance.patch({visible: true}); // so this triggers the display without animation + events.push('afterPatch'); + await shownCalledPromise; + events.push('afterShown'); + initDone.set(true); + events.push('beforePatch'); + transitionInstance.patch({visible: false}); // this uses animation + events.push('afterPatch'); + await promiseFromStore(transitionInstance.stores.hidden$).promise; + events.push('afterHidden'); + directiveInstance?.destroy?.(); + events.push('afterDestroy'); + expect(events).toEqual([ + 'afterCreateTransition', + 'transitionStart:1:domEl:hide:anim=false:ctxt=1', + 'afterDirective', + 'onVisibleChange:true', + 'transitionAbort:1', + 'transitionStart:2:domEl:show:anim=false:ctxt=1', + 'afterPatch', + 'transitionEnd:1:aborted=true', + 'transitionEnd:2:aborted=false', + 'onShown', + 'afterShown', + 'beforePatch', + 'onVisibleChange:false', + 'transitionStart:3:domEl:hide:anim=true:ctxt=3', + 'afterPatch', + 'transitionEnd:3:aborted=false', + 'onHidden', + 'afterHidden', + 'afterDestroy', + ]); + }); + + test(`changing visible to true after calling the directive (with initDone = null)`, async () => { + const element = {id: 'domEl'}; + let shownCalled: () => void; + const shownCalledPromise = new Promise((resolve) => (shownCalled = resolve)); + const transitionInstance = createTransition({ + visible: false, + onHidden, + onVisibleChange, + onShown: function () { + shownCalled(); + onShown(); + }, + transition, + }); + events.push('afterCreateTransition'); + const directiveInstance = transitionInstance.directives.directive(element); + events.push('afterDirective'); + transitionInstance.patch({visible: true}); + events.push('afterPatch'); + await shownCalledPromise; + events.push('afterShown'); + directiveInstance?.destroy?.(); + events.push('afterDestroy'); + expect(events).toEqual([ + 'afterCreateTransition', + 'transitionStart:1:domEl:hide:anim=false:ctxt=1', + 'afterDirective', + 'onVisibleChange:true', + 'transitionAbort:1', + 'transitionStart:2:domEl:show:anim=true:ctxt=1', + 'afterPatch', + 'transitionEnd:1:aborted=true', + 'transitionEnd:2:aborted=false', + 'onShown', + 'afterShown', + 'afterDestroy', + ]); + }); + + test(`setting visible with patch`, async () => { + const element = {id: 'domEl'}; + const transitionInstance = createTransition({ + transition, + onShown, + onHidden, + onVisibleChange, + }); + events.push('afterCreateTransition'); + const unsubscribeState = transitionInstance.state$.subscribe((state) => { + events.push(`state = ${JSON.stringify(state)}`); + }); + events.push('beforeDirective'); + const directiveInstance = transitionInstance.directives.directive(element); + events.push('afterDirective'); + await promiseFromStore(transitionInstance.stores.shown$).promise; + events.push('beforePatch1'); + transitionInstance.patch({visible: false}); + events.push('afterPatch1'); + await promiseFromStore(transitionInstance.stores.hidden$).promise; + events.push('beforePatch2'); + transitionInstance.patch({visible: true}); + events.push('afterPatch2'); + await promiseFromStore(transitionInstance.stores.shown$).promise; + directiveInstance?.destroy?.(); + expect(events).toEqual([ + 'afterCreateTransition', + `state = ${JSON.stringify({visible: true, element: null, elementPresent: false, transitioning: false, shown: false, hidden: false})}`, + 'beforeDirective', + 'transitionStart:1:domEl:show:anim=false:ctxt=1', + `state = ${JSON.stringify({visible: true, element, elementPresent: true, transitioning: true, shown: false, hidden: false})}`, + 'afterDirective', + 'transitionEnd:1:aborted=false', + `state = ${JSON.stringify({visible: true, element, elementPresent: true, transitioning: false, shown: true, hidden: false})}`, + 'onShown', + 'beforePatch1', + 'onVisibleChange:false', + 'transitionStart:2:domEl:hide:anim=true:ctxt=2', + `state = ${JSON.stringify({visible: false, element, elementPresent: true, transitioning: true, shown: false, hidden: false})}`, + 'afterPatch1', + 'transitionEnd:2:aborted=false', + `state = ${JSON.stringify({visible: false, element, elementPresent: true, transitioning: false, shown: false, hidden: true})}`, + 'onHidden', + 'beforePatch2', + 'onVisibleChange:true', + 'transitionStart:3:domEl:show:anim=true:ctxt=3', + `state = ${JSON.stringify({visible: true, element, elementPresent: true, transitioning: true, shown: false, hidden: false})}`, + 'afterPatch2', + 'transitionEnd:3:aborted=false', + `state = ${JSON.stringify({visible: true, element, elementPresent: true, transitioning: false, shown: true, hidden: false})}`, + 'onShown', + `state = ${JSON.stringify({visible: true, element: null, elementPresent: false, transitioning: false, shown: false, hidden: false})}`, + ]); + unsubscribeState(); + }); + + for (const animation of [true, false]) { + for (const animationOnInit of [true, false]) { + test(`check initial animation with animationOnInit = ${animationOnInit} and animation = ${animation} and visible = true`, async () => { + let element = {id: 'domEl1'}; + const transitionInstance = createTransition({ + animationOnInit, + animation, + onVisibleChange, + transition, + }); + events.push('afterCreateTransition'); + const unsubscribeState = transitionInstance.state$.subscribe((state) => { + events.push(`state = ${JSON.stringify(state)}`); + }); + events.push('beforeCallingDirective1'); + let directiveInstance = transitionInstance.directives.directive(element, {}); + events.push('afterCallingDirective1'); + await promiseFromStore(transitionInstance.stores.shown$).promise; + events.push('beforeDestroyingDirective1'); + directiveInstance?.destroy?.(); + events.push('afterDestroyingDirective1'); + element = {id: 'domEl2'}; + events.push('beforeCallingDirective2'); + directiveInstance = transitionInstance.directives.directive(element, {}); + events.push('afterCallingDirective2'); + await promiseFromStore(transitionInstance.stores.shown$).promise; + events.push('beforeDestroyingDirective2'); + directiveInstance?.destroy?.(); + events.push('afterDestroyingDirective2'); + element = {id: 'domEl3'}; + events.push('beforeCallingDirective3'); + directiveInstance = transitionInstance.directives.directive(element, {visible: false}); + events.push('afterCallingDirective3'); + await promiseFromStore(transitionInstance.stores.hidden$).promise; + events.push('beforeDestroyingDirective3'); + directiveInstance?.destroy?.(); + events.push('afterDestroyingDirective3'); + unsubscribeState(); + expect(events).toEqual([ + 'afterCreateTransition', + `state = ${JSON.stringify({visible: true, element: null, elementPresent: false, transitioning: false, shown: false, hidden: false})}`, + 'beforeCallingDirective1', + `transitionStart:1:domEl1:show:anim=${animationOnInit}:ctxt=1`, + `state = ${JSON.stringify({ + visible: true, + element: {id: 'domEl1'}, + elementPresent: true, + transitioning: true, + shown: false, + hidden: false, + })}`, + 'afterCallingDirective1', + 'transitionEnd:1:aborted=false', + `state = ${JSON.stringify({ + visible: true, + element: {id: 'domEl1'}, + elementPresent: true, + transitioning: false, + shown: true, + hidden: false, + })}`, + 'beforeDestroyingDirective1', + `state = ${JSON.stringify({visible: true, element: null, elementPresent: false, transitioning: false, shown: false, hidden: false})}`, + 'afterDestroyingDirective1', + 'beforeCallingDirective2', + `transitionStart:2:domEl2:show:anim=${animation}:ctxt=2`, + `state = ${JSON.stringify({ + visible: true, + element: {id: 'domEl2'}, + elementPresent: true, + transitioning: true, + shown: false, + hidden: false, + })}`, + 'afterCallingDirective2', + 'transitionEnd:2:aborted=false', + `state = ${JSON.stringify({ + visible: true, + element: {id: 'domEl2'}, + elementPresent: true, + transitioning: false, + shown: true, + hidden: false, + })}`, + 'beforeDestroyingDirective2', + `state = ${JSON.stringify({visible: true, element: null, elementPresent: false, transitioning: false, shown: false, hidden: false})}`, + 'afterDestroyingDirective2', + 'beforeCallingDirective3', + 'onVisibleChange:false', + `transitionStart:3:domEl3:hide:anim=false:ctxt=3`, + `state = ${JSON.stringify({ + visible: false, + element: {id: 'domEl3'}, + elementPresent: true, + transitioning: true, + shown: false, + hidden: false, + })}`, + 'afterCallingDirective3', + 'transitionEnd:3:aborted=false', + `state = ${JSON.stringify({ + visible: false, + element: {id: 'domEl3'}, + elementPresent: true, + transitioning: false, + shown: false, + hidden: true, + })}`, + 'beforeDestroyingDirective3', + `state = ${JSON.stringify({visible: false, element: null, elementPresent: false, transitioning: false, shown: false, hidden: true})}`, + 'afterDestroyingDirective3', + ]); + }); + + test(`check initial animation with animationOnInit = ${animationOnInit} and animation = ${animation} and visible = false`, async () => { + let element = {id: 'domEl1'}; + const transitionInstance = createTransition({ + animationOnInit, + animation, + visible: false, + onVisibleChange, + transition, + }); + events.push('afterCreateTransition'); + const unsubscribeState = transitionInstance.state$.subscribe((state) => { + events.push(`state = ${JSON.stringify(state)}`); + }); + events.push('beforeCallingDirective1'); + let directiveInstance = transitionInstance.directives.directive(element, {}); + events.push('afterCallingDirective1'); + await promiseFromStore(transitionInstance.stores.hidden$).promise; + events.push('beforeDestroyingDirective1'); + directiveInstance?.destroy?.(); + events.push('afterDestroyingDirective1'); + element = {id: 'domEl2'}; + events.push('beforeCallingDirective2'); + directiveInstance = transitionInstance.directives.directive(element, {visible: true}); + events.push('afterCallingDirective2'); + await promiseFromStore(transitionInstance.stores.shown$).promise; + events.push('beforeDestroyingDirective2'); + directiveInstance?.destroy?.(); + events.push('afterDestroyingDirective2'); + unsubscribeState(); + expect(events).toEqual([ + 'afterCreateTransition', + `state = ${JSON.stringify({visible: false, element: null, elementPresent: false, transitioning: false, shown: false, hidden: true})}`, + 'beforeCallingDirective1', + 'transitionStart:1:domEl1:hide:anim=false:ctxt=1', + `state = ${JSON.stringify({ + visible: false, + element: {id: 'domEl1'}, + elementPresent: true, + transitioning: true, + shown: false, + hidden: false, + })}`, + 'afterCallingDirective1', + 'transitionEnd:1:aborted=false', + `state = ${JSON.stringify({ + visible: false, + element: {id: 'domEl1'}, + elementPresent: true, + transitioning: false, + shown: false, + hidden: true, + })}`, + 'beforeDestroyingDirective1', + `state = ${JSON.stringify({visible: false, element: null, elementPresent: false, transitioning: false, shown: false, hidden: true})}`, + 'afterDestroyingDirective1', + 'beforeCallingDirective2', + 'onVisibleChange:true', + `transitionStart:2:domEl2:show:anim=${animation}:ctxt=2`, + `state = ${JSON.stringify({ + visible: true, + element: {id: 'domEl2'}, + elementPresent: true, + transitioning: true, + shown: false, + hidden: false, + })}`, + 'afterCallingDirective2', + 'transitionEnd:2:aborted=false', + `state = ${JSON.stringify({ + visible: true, + element: {id: 'domEl2'}, + elementPresent: true, + transitioning: false, + shown: true, + hidden: false, + })}`, + 'beforeDestroyingDirective2', + `state = ${JSON.stringify({visible: true, element: null, elementPresent: false, transitioning: false, shown: false, hidden: false})}`, + 'afterDestroyingDirective2', + ]); + }); + } + } +}); diff --git a/core/lib/transitions/baseTransitions.ts b/core/lib/transitions/baseTransitions.ts new file mode 100644 index 0000000000..395991f50f --- /dev/null +++ b/core/lib/transitions/baseTransitions.ts @@ -0,0 +1,360 @@ +import {batch, computed, derived, writable} from '@amadeus-it-group/tansu'; +import type {ConfigValidator, PropsConfig} from '../services'; +import { + bindableDerived, + createStoreDirective, + directiveSubscribe, + directiveUpdate, + mergeDirectives, + stateStores, + writablesForProps, +} from '../services'; +import {typeBoolean, typeFunction} from '../services/writables'; +import type {Directive, Widget} from '../types'; + +/** + * Function that implements a transition. + */ +export type TransitionFn = ( + /** + * Element on which the transition should be applied. + */ + element: HTMLElement, + + /** + * Whether the element should be shown or hidden. + */ + direction: 'show' | 'hide', + + /** + * Whether the transition should be animated. + */ + animation: boolean, + + /** + * Signal allowing to stop the transition while running. + */ + signal: AbortSignal, + + /** + * Context of the current transition. It is reused between calls if the previous transition was stopped while running on the same element. + */ + context: object +) => Promise; + +export interface TransitionProps { + /** + * Transition to be called. + */ + transition: TransitionFn; + + /** + * Whether the element should be visible when the transition is completed. + */ + visible: boolean; + + /* + * Whether the transition should be animated. + */ + animation: boolean; + + /** + * If the element is initially visible, whether the element should be animated when first displayed. + */ + animationOnInit: boolean; + + /** + * Whether initialization is finished. It determines which setting between {@link TransitionProps.animation} + * and {@link TransitionProps.animationOnInit} is used to enable or disable animations. + * @remarks + * If it is `true`, initialization is considered finished, and {@link TransitionProps.animationOnInit} is no longer used. + * Otherwise, initialization is considered unfinished and {@link TransitionProps.animationOnInit} is used instead of {@link TransitionProps.animation}. + * If it is `null`, it will be set to `true` automatically when the directive is called with a DOM element. + * If it is `false`, it will not be updated automatically. + */ + initDone: boolean | null; + + /** + * Function to be called when the transition is completed and the element is visible. + */ + onShown: () => void; + + /** + * Function to be called when the transition is completed and the element is not visible. + */ + onHidden: () => void; + + /** + * Function to be called when the visible property changes. + * + * @param visible - new value of the visible propery + */ + onVisibleChange: (visible: boolean) => void; +} + +/** + * Transition state. + */ +export interface TransitionState { + /** + * Whether the element is visible or will be visible when the transition is completed. + */ + visible: boolean; + + /** + * Whether the element to be animated is present in the DOM. + */ + elementPresent: boolean; + + /** + * Reference to the DOM element. + */ + element: HTMLElement | null; + + /** + * Whether a transition is currently running. + */ + transitioning: boolean; + + /** + * Equals: {@link TransitionState.visible | visible} && ! {@link TransitionState.transitioning | transitioning} + */ + shown: boolean; + + /** + * Equals: ! {@link TransitionState.visible | visible} && ! {@link TransitionState.transitioning | transitioning} + */ + hidden: boolean; +} + +export interface TransitionApi { + /** + * Runs the transition to show the element. It is equivalent to {@link TransitionApi.toggle | toggle} with true as the first parameter. + * + * @param animation - whether the transition should be animated. If the parameter is not defined, the {@link TransitionProps.animation | animation } property is used. + * + * @returns A promise that is fulfilled when the transition is completed. If the transition is canceled, or if the same transition was + * already running, the promise never completes. + */ + show: (animation?: boolean) => Promise; + + /** + * Runs the transition to hide the element. It is equivalent to {@link TransitionApi.toggle | toggle} with false as the first parameter. + * + * @param animation - whether the transition should be animated. If the parameter is not defined, the {@link TransitionProps.animation | animation } property is used. + * + * @returns A promise that is fulfilled when the transition is completed. If the transition is canceled, or if the same transition was + * already running, the promise never completes. + */ + hide: (animation?: boolean) => Promise; + + /** + * Runs the transition to show or hide the element depending on the first parameter. + * + * @param visible - whether the element should be made visible or not. If the parameter is not defined, the opposite of the current {@link TransitionProps.visible | visible } property is used. + * @param animation - whether the transition should be animated. If the parameter is not defined, the {@link TransitionProps.animation | animation } property is used. + * + * @returns A promise that is fulfilled when the transition is completed. If the transition is canceled, or if the same transition was + * already running, the promise never completes. + */ + toggle: (visible?: boolean, animation?: boolean) => Promise; +} + +export interface TransitionDirectives { + directive: Directive>; +} + +export type TransitionWidget = Widget; + +const noop = () => {}; + +const neverEndingPromise = new Promise(noop); + +export const noAnimation: TransitionFn = async (element, direction) => { + element.style.display = direction === 'show' ? '' : 'none'; +}; + +const defaultValues: TransitionProps = { + animation: true, + animationOnInit: false, + initDone: null, + visible: true, + transition: noAnimation, + onShown: noop, + onHidden: noop, + onVisibleChange: noop, +}; + +const configValidator: ConfigValidator = { + animation: typeBoolean, + animationOnInit: typeBoolean, + visible: typeBoolean, + transition: typeFunction, + onShown: typeFunction, + onHidden: typeFunction, +}; + +const promiseWithResolve = () => { + let resolve: (value: void | Promise) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return {promise, resolve: resolve!}; +}; + +export const createTransition = (config?: PropsConfig): TransitionWidget => { + const [{animation$, initDone$, visible$: requestedVisible$, transition$, onShown$, onHidden$, onVisibleChange$, animationOnInit$}, patch] = + writablesForProps(defaultValues, config, configValidator); + const {element$, directive: storeDirective} = createStoreDirective(); + const elementPresent$ = computed(() => !!element$()); + const visible$ = bindableDerived(onVisibleChange$, [requestedVisible$], ([visible]) => visible); + const currentTransition$ = writable( + null as null | { + abort: AbortController; + visible: boolean; + animation: boolean; + context: object; + element: HTMLElement; + transitionFn: TransitionFn; + promise: Promise; + } + ); + const transitioning$ = computed(() => !!currentTransition$()); + const stop = () => { + let context: object | undefined; + currentTransition$.update((currentTransition) => { + currentTransition?.abort.abort(); + context = currentTransition?.context; + return null; + }); + return context; + }; + + const runTransition = (visible: boolean, animation: boolean, element: HTMLElement, transitionFn: TransitionFn) => + batch(() => { + const abort = new AbortController(); + const signal = abort.signal; + const context = stop() ?? {}; + const {promise, resolve} = promiseWithResolve(); + const currentTransition = { + abort, + animation, + visible, + context, + element, + transitionFn, + promise, + }; + currentTransition$.set(currentTransition); + resolve!( + (async () => { + try { + await transitionFn(element, visible ? 'show' : 'hide', animation, signal, context); + } finally { + if (signal.aborted) { + await neverEndingPromise; + } else { + currentTransition$.set(null); + (visible ? onShown$ : onHidden$)()?.(); + } + } + })() + ); + return currentTransition; + }); + + const shown$ = computed(() => !transitioning$() && visible$() && elementPresent$()); + const hidden$ = computed(() => !transitioning$() && !visible$()); + const effectiveAnimation$ = computed(() => (initDone$() ? animation$() : animationOnInit$())); + + const animationFromToggle$ = writable(null as null | boolean); + let previousElement: HTMLElement | null; + let previousVisible = requestedVisible$(); + let pendingTransition: null | ({animation: boolean} & ReturnType) = null; + const visibleAction$ = derived( + [visible$, element$, effectiveAnimation$, animationFromToggle$, transition$, currentTransition$], + ([visible, element, animation, animationFromToggle, transition, currentTransition]) => { + const elementChanged = previousElement !== element; + previousElement = element; + const visibleChanged = previousVisible !== visible; + previousVisible = visible; + if (element) { + if (initDone$() == null) { + initDone$.set(true); + } + const interruptAnimation = animationFromToggle != null && currentTransition && currentTransition.animation != animationFromToggle; + if (elementChanged || visibleChanged || interruptAnimation) { + if (visibleChanged || animationFromToggle != null) { + pendingTransition = null; + } + const animate = animationFromToggle ?? pendingTransition?.animation ?? (elementChanged && !visible ? false : animation); + currentTransition = runTransition(visible, animate, element, transition); + pendingTransition?.resolve(currentTransition.promise); + pendingTransition = null; + } + } else { + if (elementChanged) { + // just removed from the DOM: stop animation if any + stop(); + currentTransition = null; + } + if (visibleChanged || (visible && pendingTransition?.animation !== animationFromToggle)) { + pendingTransition = + visible && animationFromToggle != null + ? { + // toggle was called to display the element, but the element is not yet in the DOM + // let's keep the animation setting from toggle and provide the promise for the end of toggle + animation: animationFromToggle, + ...promiseWithResolve(), + } + : null; + } + } + return pendingTransition?.promise ?? currentTransition?.promise; + } + ); + + let lastToggle = {}; + const toggle = async (visible = !requestedVisible$(), animation = effectiveAnimation$()): Promise => { + const currentToggle = {}; + lastToggle = currentToggle; + try { + await batch(() => { + try { + animationFromToggle$.set(animation); + requestedVisible$.set(visible); + return visibleAction$(); + } finally { + animationFromToggle$.set(null); + } + }); + } finally { + if (lastToggle !== currentToggle) { + await neverEndingPromise; + } + } + }; + + const directive = mergeDirectives>(storeDirective, directiveUpdate(patch), directiveSubscribe(visibleAction$)); + + return { + ...stateStores({ + visible$, + element$, + elementPresent$, + transitioning$, + shown$, + hidden$, + }), + patch, + directives: { + directive, + }, + actions: {}, + api: { + show: toggle.bind(null, true), + hide: toggle.bind(null, false), + toggle, + }, + }; +}; diff --git a/core/lib/transitions/bootstrap/collapse.ts b/core/lib/transitions/bootstrap/collapse.ts new file mode 100644 index 0000000000..c8bb825007 --- /dev/null +++ b/core/lib/transitions/bootstrap/collapse.ts @@ -0,0 +1,18 @@ +import type {CollapseConfig} from '../collapse'; +import {createCollapseTransition} from '../collapse'; + +const verticalConfig: CollapseConfig = { + dimension: 'height', + hideClasses: ['collapse'], + showClasses: ['collapse', 'show'], + animationPendingClasses: ['collapsing'], +}; +const horizontalConfig: CollapseConfig = { + dimension: 'width', + hideClasses: ['collapse', 'collapse-horizontal'], + showClasses: ['collapse', 'collapse-horizontal', 'show'], + animationPendingClasses: ['collapsing', 'collapse-horizontal'], +}; + +export const collapseVerticalTransition = createCollapseTransition(verticalConfig); +export const collapseHorizontalTransition = createCollapseTransition(horizontalConfig); diff --git a/core/lib/transitions/bootstrap/fade.ts b/core/lib/transitions/bootstrap/fade.ts new file mode 100644 index 0000000000..3660bc6422 --- /dev/null +++ b/core/lib/transitions/bootstrap/fade.ts @@ -0,0 +1,8 @@ +import {createSimpleClassTransition} from '../simpleClassTransition'; + +export const fadeTransition = createSimpleClassTransition({ + animationPendingClasses: ['fade'], + animationPendingShowClasses: ['show'], + showClasses: ['show'], + hideClasses: ['d-none'], +}); diff --git a/core/lib/transitions/bootstrap/index.ts b/core/lib/transitions/bootstrap/index.ts new file mode 100644 index 0000000000..69d14715ab --- /dev/null +++ b/core/lib/transitions/bootstrap/index.ts @@ -0,0 +1,2 @@ +export * from './collapse'; +export * from './fade'; diff --git a/core/lib/transitions/collapse.spec.ts b/core/lib/transitions/collapse.spec.ts new file mode 100644 index 0000000000..3153308e50 --- /dev/null +++ b/core/lib/transitions/collapse.spec.ts @@ -0,0 +1,82 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; +import type {Directive} from '../types'; +import type {TransitionFn, TransitionWidget} from './baseTransitions'; +import {createTransition} from './baseTransitions'; +import {createCollapseTransition} from './collapse'; + +describe('createCollapseTransition', () => { + let element: HTMLElement; + let myTransition: TransitionFn; + let transition: TransitionWidget; + let directiveInstance: ReturnType; + + beforeEach(() => { + const style = document.createElement('style'); + style.innerHTML = ` + .anim { transition-property: height; transition-duration: 0.1s; transition-delay: 0s; } + .hide { display: none; } + .show { display: block; } + `; + document.body.appendChild(style); + element = document.createElement('div'); + document.body.appendChild(element); + myTransition = createCollapseTransition({ + animationPendingClasses: ['anim'], + hideClasses: ['hide'], + showClasses: ['show'], + }); + transition = createTransition({ + visible: false, + transition: myTransition, + }); + directiveInstance = transition.directives.directive(element); + }); + + afterEach(() => { + directiveInstance?.destroy?.(); + document.body.innerHTML = ''; + }); + + const checkClasses = (classes: string[]) => { + expect([...element.classList.values()]).toEqual(classes); + }; + + test('animations enabled', async () => { + checkClasses(['hide']); + let promise = transition.api.toggle(true, true); + checkClasses(['anim']); + await promise; + checkClasses(['show']); + promise = transition.api.toggle(false, true); + checkClasses(['anim']); + await promise; + checkClasses(['hide']); + }); + + test('disabled animations (false in toggle)', async () => { + const reflow = vi.spyOn(element, 'getBoundingClientRect'); + checkClasses(['hide']); + let promise = transition.api.toggle(true, false); + checkClasses(['show']); + await promise; + checkClasses(['show']); + promise = transition.api.toggle(false, false); + checkClasses(['hide']); + await promise; + checkClasses(['hide']); + expect(reflow).not.toHaveBeenCalled(); + }); + + test('disabled animations (transition-property: none in css)', async () => { + checkClasses(['hide']); + element.style.transitionProperty = 'none'; + let promise = transition.api.toggle(true, true); + checkClasses(['show']); + await promise; + checkClasses(['show']); + promise = transition.api.toggle(false, true); + checkClasses(['hide']); + await promise; + checkClasses(['hide']); + }); +}); diff --git a/core/lib/transitions/collapse.ts b/core/lib/transitions/collapse.ts new file mode 100644 index 0000000000..e59a8525f0 --- /dev/null +++ b/core/lib/transitions/collapse.ts @@ -0,0 +1,56 @@ +import {createCSSTransition} from './cssTransitions'; +import {addClasses, reflow, removeClasses} from './utils'; + +export interface CollapseContext { + maxSize?: string; + minSize?: string; +} + +export interface CollapseConfig { + dimension?: 'width' | 'height'; + showClasses?: string[]; + hideClasses?: string[]; + animationPendingClasses?: string[]; +} + +export const createCollapseTransition = ({dimension = 'height', showClasses, hideClasses, animationPendingClasses}: CollapseConfig = {}) => + createCSSTransition((element: HTMLElement, direction, animation, context: CollapseContext) => { + if (animation) { + let {maxSize, minSize} = context; + + if (!maxSize) { + // measure the element in its show state + removeClasses(element, animationPendingClasses); + removeClasses(element, hideClasses); + addClasses(element, showClasses); + maxSize = element.getBoundingClientRect()[dimension] + 'px'; + context.maxSize = maxSize; + } + + if (!minSize) { + // measure the element in its hide state + removeClasses(element, animationPendingClasses); + removeClasses(element, showClasses); + addClasses(element, hideClasses); + minSize = element.getBoundingClientRect()[dimension] + 'px'; + context.minSize = minSize; + } + + removeClasses(element, showClasses); + removeClasses(element, hideClasses); + + const values = direction === 'show' ? [minSize, maxSize] : [maxSize, minSize]; + element.style[dimension] = values[0]; + reflow(element); // explicitly applies the initial state + addClasses(element, animationPendingClasses); + reflow(element); + element.style[dimension] = values[1]; + } else { + removeClasses(element, direction === 'show' ? hideClasses : showClasses); + } + return () => { + removeClasses(element, animationPendingClasses); + addClasses(element, direction === 'show' ? showClasses : hideClasses); + element.style[dimension] = ''; + }; + }); diff --git a/core/lib/transitions/cssTransitions.spec.ts b/core/lib/transitions/cssTransitions.spec.ts new file mode 100644 index 0000000000..cf64791f65 --- /dev/null +++ b/core/lib/transitions/cssTransitions.spec.ts @@ -0,0 +1,199 @@ +import {test, describe, beforeAll, afterAll, expect} from 'vitest'; +import {EventEmitter} from 'events'; +import {createCSSTransition} from './cssTransitions'; +import type {TransitionProps} from './baseTransitions'; +import {createTransition} from './baseTransitions'; + +describe(`createCSSTransition`, () => { + const createElement = (): HTMLElement => { + const eventEmitter = new EventEmitter(); + return { + style: {transitionProperty: 'all', transitionDelay: '0s', transitionDuration: '0s'}, + addEventListener: function (type: string, callback: EventListener): void { + eventEmitter.addListener(type, callback); + }, + dispatchEvent: function (event: Event): boolean { + eventEmitter.emit(event.type, event); + return true; + }, + removeEventListener: function (type: string, callback: EventListener): void { + eventEmitter.removeListener(type, callback); + }, + } as any as HTMLElement; + }; + + const callTransitionShow = async (element: HTMLElement, props: Partial) => { + const transitionInstance = createTransition({animationOnInit: true, ...props}); + const directiveInstance = transitionInstance.directives.directive(element); + await transitionInstance.api.show(); + directiveInstance?.destroy?.(); + }; + + beforeAll(() => { + (global as any).window = { + getComputedStyle(el: HTMLElement) { + return el.style; + }, + }; + }); + + afterAll(() => { + delete (global as any).window; + }); + + test(`simple transition (setTimeout)`, async () => { + const events: string[] = []; + const element = createElement(); + let duration: number | undefined; + const transition = createCSSTransition((e, d) => { + const timeBefore = performance.now(); + events.push('startFn'); + expect(e).toBe(element); + expect(d).toBe('show'); + e.style.transitionDuration = '0.025s'; + return () => { + duration = Math.round(performance.now() - timeBefore); + events.push('endFn'); + }; + }); + events.push('before'); + // we do not emit transitionend, so this relies on setTimeout: + await callTransitionShow(element, {transition}); + events.push('after'); + expect(events).toEqual(['before', 'startFn', 'endFn', 'after']); + expect(duration).toBeGreaterThanOrEqual(25); + expect(duration).toBeLessThan(100); + }); + + test(`transition with no endFn`, async () => { + const events: string[] = []; + const element = createElement(); + let timeBefore: number | undefined; + const transition = createCSSTransition((e, d) => { + timeBefore = performance.now(); + events.push('startFn'); + expect(e).toBe(element); + expect(d).toBe('show'); + e.style.transitionDuration = '0.025s'; + }); + events.push('before'); + // we do not emit transitionend, so this relies on setTimeout: + await callTransitionShow(element, {transition}); + const duration = Math.round(performance.now() - timeBefore!); + events.push('after'); + expect(events).toEqual(['before', 'startFn', 'after']); + expect(duration).toBeGreaterThanOrEqual(25); + expect(duration).toBeLessThan(100); + }); + + test(`simple transition (transitionend)`, async () => { + const events: string[] = []; + const element = createElement(); + let duration: number | undefined; + const transition = createCSSTransition((e, d) => { + const timeBefore = performance.now(); + events.push('startFn'); + expect(e).toBe(element); + expect(d).toBe('show'); + e.style.transitionDuration = '1s'; // much higher value than the transitionend event (25ms) + // to check that the transitionend event has an effect + return () => { + duration = Math.round(performance.now() - timeBefore); + events.push('endFn'); + }; + }); + events.push('before'); + const promise = callTransitionShow(element, {transition}); + events.push('beforeTimeout'); + await new Promise((resolve) => setTimeout(resolve, 25)); + events.push('afterTimeout'); + element.dispatchEvent({type: 'transitionend', target: element} as any); + await promise; + events.push('after'); + expect(events).toEqual(['before', 'startFn', 'beforeTimeout', 'afterTimeout', 'endFn', 'after']); + expect(duration).toBeGreaterThanOrEqual(25); + expect(duration).toBeLessThan(100); + }); + + test(`transition disabled`, async () => { + const events: string[] = []; + const element = createElement(); + // this disables the transition: + element.style.transitionProperty = 'none'; + let duration: number | undefined; + const transition = createCSSTransition((e, d) => { + events.push('startFn'); + const timeBefore = performance.now(); + expect(e).toBe(element); + expect(d).toBe('show'); + e.style.transitionDuration = '0.2s'; + return () => { + duration = Math.round(performance.now() - timeBefore); + events.push('endFn'); + }; + }); + events.push('before'); + await callTransitionShow(element, {transition}); + events.push('after'); + expect(events).toEqual(['before', 'startFn', 'endFn', 'after']); + expect(duration).toBeLessThan(10); + }); + + test(`animation disabled`, async () => { + const events: string[] = []; + const element = createElement(); + const transition = createCSSTransition((e, d, a) => { + events.push('startFn'); + expect(e).toBe(element); + expect(d).toBe('show'); + expect(a).toBe(false); + e.style.transitionDuration = '0.2s'; + return () => { + events.push('endFn'); + }; + }); + events.push('before'); + const promise = callTransitionShow(element, {transition, animationOnInit: false, animation: false}); + events.push('after'); + await promise; + events.push('afterAsync'); + expect(events).toEqual(['before', 'startFn', 'endFn', 'after', 'afterAsync']); + }); + + test(`stopped transition`, async () => { + const events: string[] = []; + const element = createElement(); + const cssTransition = createTransition({ + animationOnInit: true, + transition: createCSSTransition((e, d, a, c: {used?: boolean}) => { + events.push(`startFn:${d}:${c.used ?? false}`); + c.used = true; + expect(e).toBe(element); + expect(a).toBe(true); + e.style.transitionDuration = '1s'; // much higher value than the time we start the second transition + return () => { + events.push(`endFn:${d}`); + }; + }), + }); + events.push('before'); + const timeBefore = performance.now(); + const directiveInstance = cssTransition.directives.directive(element); + const promise1 = cssTransition.api.show(); + promise1.finally(() => { + throw new Error('promise1 is expected not to resolve'); + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + events.push('middle'); + const promise2 = cssTransition.api.hide(); + await new Promise((resolve) => setTimeout(resolve, 200)); + element.dispatchEvent({type: 'transitionend', target: element} as any); + await promise2; + const duration = Math.round(performance.now() - timeBefore); + events.push('after'); + expect(events).toEqual(['before', 'startFn:show:false', 'middle', 'startFn:hide:true', 'endFn:hide', 'after']); + expect(duration).toBeGreaterThanOrEqual(400); + expect(duration).toBeLessThan(600); + directiveInstance?.destroy?.(); + }); +}); diff --git a/core/lib/transitions/cssTransitions.ts b/core/lib/transitions/cssTransitions.ts new file mode 100644 index 0000000000..6978b23533 --- /dev/null +++ b/core/lib/transitions/cssTransitions.ts @@ -0,0 +1,41 @@ +import type {TransitionFn} from './baseTransitions'; +import {promiseFromEvent, promiseFromTimeout} from './utils'; + +export function hasTransition(element: HTMLElement) { + return window.getComputedStyle(element).transitionProperty !== 'none'; +} + +export function getTransitionDurationMs(element: HTMLElement) { + const {transitionDelay, transitionDuration} = window.getComputedStyle(element); + const transitionDelaySec = parseFloat(transitionDelay); + const transitionDurationSec = parseFloat(transitionDuration); + + return (transitionDelaySec + transitionDurationSec) * 1000; +} + +const noop = () => { + /* do nothing */ +}; + +export type CSSTransitionFn = (element: HTMLElement, direction: 'show' | 'hide', animation: boolean, context: object) => void | (() => void); + +export const createCSSTransition = + (start: CSSTransitionFn): TransitionFn => + async (element, direction, animation, signal, context) => { + const endFn = start(element, direction, animation, context) ?? noop; + + if (animation && hasTransition(element)) { + const abort = promiseFromEvent(signal, 'abort'); + const transitionEnd = promiseFromEvent(element, 'transitionend'); + const timer = promiseFromTimeout(getTransitionDurationMs(element)); + + await Promise.race([abort.promise, transitionEnd.promise, timer.promise]); + + abort.unsubscribe(); + transitionEnd.unsubscribe(); + timer.unsubscribe(); + } + if (!signal.aborted) { + endFn(); + } + }; diff --git a/core/lib/transitions/index.ts b/core/lib/transitions/index.ts new file mode 100644 index 0000000000..a7377ffad7 --- /dev/null +++ b/core/lib/transitions/index.ts @@ -0,0 +1,5 @@ +export * from './baseTransitions'; +export * from './cssTransitions'; +export * from './simpleClassTransition'; +import * as bootstrap from './bootstrap'; +export {bootstrap}; diff --git a/core/lib/transitions/simpleClassTransition.spec.ts b/core/lib/transitions/simpleClassTransition.spec.ts new file mode 100644 index 0000000000..b6d55d22ca --- /dev/null +++ b/core/lib/transitions/simpleClassTransition.spec.ts @@ -0,0 +1,112 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; +import type {Directive} from '../types'; +import type {TransitionFn, TransitionWidget} from './baseTransitions'; +import {createTransition} from './baseTransitions'; +import {createSimpleClassTransition} from './simpleClassTransition'; + +describe('createSimpleClassTransition', () => { + let element: HTMLElement; + let myTransition: TransitionFn; + let transition: TransitionWidget; + let directiveInstance: ReturnType; + + beforeEach(() => { + const style = document.createElement('style'); + style.innerHTML = ` + .anim { transition-property: opacity; transition-duration: 0.1s; transition-delay: 0s; } + .anim-hide, hide { opacity: 0; } + .anim-show, show { opacity: 100%; } + `; + document.body.appendChild(style); + element = document.createElement('div'); + document.body.appendChild(element); + myTransition = createSimpleClassTransition({ + animationPendingClasses: ['anim'], + animationPendingHideClasses: ['anim-hide'], + animationPendingShowClasses: ['anim-show'], + hideClasses: ['hide'], + showClasses: ['show'], + }); + transition = createTransition({ + visible: false, + transition: myTransition, + }); + directiveInstance = transition.directives.directive(element); + }); + + afterEach(() => { + directiveInstance?.destroy?.(); + document.body.innerHTML = ''; + }); + + const checkClasses = (classes: string[]) => { + expect([...element.classList.values()]).toEqual(classes); + }; + + test('animations enabled', async () => { + checkClasses(['hide']); + let promise = transition.api.toggle(true, true); + checkClasses(['anim', 'anim-show']); + await promise; + checkClasses(['show']); + promise = transition.api.toggle(false, true); + checkClasses(['anim', 'anim-hide']); + await promise; + checkClasses(['hide']); + }); + + test('show animation reverted', async () => { + checkClasses(['hide']); + const promise1 = transition.api.toggle(true, true); + checkClasses(['anim', 'anim-show']); + const promise2 = transition.api.toggle(false, true); + checkClasses(['anim', 'anim-hide']); + await promise2; + checkClasses(['hide']); + promise1.finally(() => { + throw new Error('promise1 is expected not to resolve'); + }); + }); + + test('hide animation reverted', async () => { + checkClasses(['hide']); + await transition.api.toggle(true, false); + checkClasses(['show']); + const promise1 = transition.api.toggle(false, true); + checkClasses(['anim', 'anim-hide']); + const promise2 = transition.api.toggle(true, true); + checkClasses(['anim', 'anim-show']); + await promise2; + checkClasses(['show']); + promise1.finally(() => { + throw new Error('promise1 is expected not to resolve'); + }); + }); + + test('disabled animations (false in toggle)', async () => { + const reflow = vi.spyOn(element, 'getBoundingClientRect'); + checkClasses(['hide']); + let promise = transition.api.toggle(true, false); + checkClasses(['show']); + await promise; + checkClasses(['show']); + promise = transition.api.toggle(false, false); + checkClasses(['hide']); + await promise; + checkClasses(['hide']); + expect(reflow).not.toHaveBeenCalled(); + }); + + test('disabled animations (transition-property: none in css)', async () => { + checkClasses(['hide']); + element.style.transitionProperty = 'none'; + let promise = transition.api.toggle(true, true); + checkClasses(['show']); + await promise; + checkClasses(['show']); + promise = transition.api.toggle(false, true); + checkClasses(['hide']); + await promise; + checkClasses(['hide']); + }); +}); diff --git a/core/lib/transitions/simpleClassTransition.ts b/core/lib/transitions/simpleClassTransition.ts new file mode 100644 index 0000000000..d21b8c0cbc --- /dev/null +++ b/core/lib/transitions/simpleClassTransition.ts @@ -0,0 +1,48 @@ +import {createCSSTransition} from './cssTransitions'; +import {addClasses, reflow, removeClasses} from './utils'; + +export interface SimpleClassTransitionConfig { + animationPendingClasses?: string[]; + animationPendingShowClasses?: string[]; + animationPendingHideClasses?: string[]; + showClasses?: string[]; + hideClasses?: string[]; +} + +export interface SimpleClassTransitionContext { + started?: boolean; +} + +export const createSimpleClassTransition = ({ + animationPendingClasses, + animationPendingShowClasses, + animationPendingHideClasses, + showClasses, + hideClasses, +}: SimpleClassTransitionConfig) => + createCSSTransition((element, direction, animation, context: SimpleClassTransitionContext) => { + removeClasses(element, showClasses); + removeClasses(element, hideClasses); + if (animation) { + removeClasses(element, direction === 'show' ? animationPendingHideClasses : animationPendingShowClasses); + if (!context.started) { + context.started = true; + // if the animation is starting, explicitly sets the initial state (reverse of the direction) + // so that it is not impacted by another reflow done somewhere else before we had time to put + // the right classes: + const classes = direction === 'show' ? hideClasses : showClasses; + addClasses(element, classes); + reflow(element); + removeClasses(element, classes); + } + addClasses(element, animationPendingClasses); + reflow(element); + addClasses(element, direction === 'show' ? animationPendingShowClasses : animationPendingHideClasses); + } + return () => { + removeClasses(element, animationPendingClasses); + removeClasses(element, animationPendingShowClasses); + removeClasses(element, animationPendingHideClasses); + addClasses(element, direction === 'show' ? showClasses : hideClasses); + }; + }); diff --git a/core/lib/transitions/utils.spec.ts b/core/lib/transitions/utils.spec.ts new file mode 100644 index 0000000000..4a395eb86d --- /dev/null +++ b/core/lib/transitions/utils.spec.ts @@ -0,0 +1,136 @@ +import EventEmitter from 'events'; +import {writable} from '@amadeus-it-group/tansu'; +import {describe, test, expect, vi} from 'vitest'; +import {promiseFromStore, promiseFromEvent, promiseFromTimeout} from './utils'; + +const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + +describe(`promiseFromStore`, () => { + test(`already truthy store`, async () => { + const value = {}; + const onUnsubscribe = vi.fn(); + const onUse = vi.fn(() => onUnsubscribe); + const store = writable(value, onUse); // already truthy store + const res = promiseFromStore(store); + expect(onUse).toHaveBeenCalledTimes(1); + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + expect(await res.promise).toBe(value); + }); + + test(`not calling unsubscribe in time`, async () => { + const value = {}; + const onUnsubscribe = vi.fn(); + const onUse = vi.fn(() => onUnsubscribe); + const store = writable(null as any, onUse); + const res = promiseFromStore(store); + expect(onUse).toHaveBeenCalledTimes(1); + expect(onUnsubscribe).not.toHaveBeenCalled(); + // non-truthy values should not trigger resolve: + store.set(0); + store.set(false); + // this value will trigger resolve: + store.set(value); + expect(onUse).toHaveBeenCalledTimes(1); + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + expect(await res.promise).toBe(value); + res.unsubscribe(); + expect(onUse).toHaveBeenCalledTimes(1); + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + }); + + test(`calling unsubscribe`, async () => { + const value = {}; + const onUnsubscribe = vi.fn(); + const onUse = vi.fn(() => onUnsubscribe); + const store = writable(null as any, onUse); + const res = promiseFromStore(store); + expect(onUse).toHaveBeenCalledTimes(1); + expect(onUnsubscribe).not.toHaveBeenCalled(); + res.promise.finally(() => { + throw new Error('res.promise is expected not to resolve'); + }); + // non-truthy values should not trigger promise resolution: + store.set(0); + store.set(false); + // calling unsubscribe + res.unsubscribe(); + expect(onUse).toHaveBeenCalledTimes(1); + expect(onUnsubscribe).toHaveBeenCalledTimes(1); + store.set(value); // it is now too late to set the truthy value, the promise will not be resolved + await new Promise((resolve) => setTimeout(resolve, 200)); + }); +}); + +describe(`promiseFromTimeout`, () => { + test(`not calling unsubscribe in time`, async () => { + clearTimeoutSpy.mockClear(); + const timeBefore = performance.now(); + const res = promiseFromTimeout(100); + await res.promise; + const timeAfter = performance.now(); + const actualTime = Math.round(timeAfter - timeBefore); + expect(actualTime).toBeGreaterThanOrEqual(100); + expect(actualTime).toBeLessThan(150); + res.unsubscribe(); + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + }); + + test(`calling unsubscribe`, async () => { + clearTimeoutSpy.mockClear(); + const res = promiseFromTimeout(100); + res.promise.finally(() => { + throw new Error('res.promise is expected not to resolve'); + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + res.unsubscribe(); + expect(clearTimeoutSpy).toHaveBeenCalledOnce(); + await new Promise((resolve) => setTimeout(resolve, 200)); + res.unsubscribe(); + expect(clearTimeoutSpy).toHaveBeenCalledOnce(); + }); +}); + +describe(`promiseFromEvent`, () => { + const createEventTarget = (): EventTarget => { + const eventEmitter = new EventEmitter(); + return { + addEventListener: function (type: string, callback: EventListener): void { + eventEmitter.addListener(type, callback); + }, + dispatchEvent: function (event: Event): boolean { + eventEmitter.emit(event.type, event); + return true; + }, + removeEventListener: function (type: string, callback: EventListener): void { + eventEmitter.removeListener(type, callback); + }, + }; + }; + + test(`not calling unsubscribe in time`, async () => { + const target = createEventTarget(); + const removeEventListener = vi.spyOn(target, 'removeEventListener'); + const res = promiseFromEvent(target, 'something'); + const event = {type: 'something', target} as Event; + target.dispatchEvent(event); + expect(removeEventListener).toHaveBeenCalledOnce(); + res.unsubscribe(); + const value = await res.promise; + expect(value).toBe(event); + expect(removeEventListener).toHaveBeenCalledOnce(); + }); + + test(`calling unsubscribe in time`, async () => { + const target = createEventTarget(); + const removeEventListener = vi.spyOn(target, 'removeEventListener'); + const res = promiseFromEvent(target, 'something'); + target.dispatchEvent({type: 'something', target: {}} as Event); // not the right target, should be ignored + res.promise.finally(() => { + throw new Error('res.promise is expected not to resolve'); + }); + res.unsubscribe(); + expect(removeEventListener).toHaveBeenCalledOnce(); + target.dispatchEvent({type: 'something', target} as Event); + await new Promise((resolve) => setTimeout(resolve, 200)); + }); +}); diff --git a/core/lib/transitions/utils.ts b/core/lib/transitions/utils.ts new file mode 100644 index 0000000000..f5f01d3d5a --- /dev/null +++ b/core/lib/transitions/utils.ts @@ -0,0 +1,87 @@ +import type {ReadableSignal} from '@amadeus-it-group/tansu'; + +const noop = () => { + /* nothing to do*/ +}; + +const truthyValue = (value: unknown) => !!value; + +export const promiseFromStore = (store: ReadableSignal, condition: (value: T) => boolean = truthyValue) => { + let resolve: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + let unsubscribe = () => { + storeUnsubscribe(); + unsubscribe = noop; + }; + let storeUnsubscribe = noop; + storeUnsubscribe = store.subscribe((value) => { + if (condition(value)) { + resolve(value); + unsubscribe(); + } + }); + if (unsubscribe === noop) { + storeUnsubscribe(); + } + return { + promise, + unsubscribe() { + unsubscribe(); + }, + }; +}; + +export const promiseFromEvent = (element: EventTarget, event: string) => { + let resolve: (event: Event) => void; + const promise = new Promise((r) => (resolve = r)); + let unsubscribe = () => { + element.removeEventListener(event, eventListener); + unsubscribe = noop; + }; + const eventListener = (event: Event) => { + if (event.target === element) { + resolve(event); + unsubscribe(); + } + }; + element.addEventListener(event, eventListener); + return { + promise, + unsubscribe() { + unsubscribe(); + }, + }; +}; + +export const promiseFromTimeout = (delay: number) => { + let timeout: any; + return { + promise: new Promise((r) => { + timeout = setTimeout(() => { + timeout = undefined; + r(); + }, delay); + }), + unsubscribe() { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + }, + }; +}; + +export function reflow(element: HTMLElement = document.body) { + element.getBoundingClientRect(); +} + +export const addClasses = (element: HTMLElement, classes?: string[]) => { + if (classes && classes.length > 0) { + element.classList.add(...classes); + } +}; +export const removeClasses = (element: HTMLElement, classes?: string[]) => { + if (classes && classes.length > 0) { + element.classList.remove(...classes); + } +}; diff --git a/core/lib/types.ts b/core/lib/types.ts new file mode 100644 index 0000000000..96b20aafd2 --- /dev/null +++ b/core/lib/types.ts @@ -0,0 +1,42 @@ +import type {ReadableSignal, SubscribableStore} from '@amadeus-it-group/tansu'; +import type {PropsConfig} from './services'; + +export interface Widget< + Props extends object = object, + State extends object = object, + Api extends object = object, + Actions extends object = object, + Directives extends object = object +> { + state$: ReadableSignal; + stores: {[K in keyof State as `${K & string}$`]: ReadableSignal}; + + /** + * Modify the parameter values, and recalculate the stores accordingly + */ + patch(parameters: Partial): void; + directives: Directives; + actions: Actions; + api: Api; +} + +export interface WidgetSlotContext { + state: WidgetState; + widget: Pick; +} + +export const toSlotContextWidget = (w: W): WidgetSlotContext['widget'] => ({ + actions: w.actions, + api: w.api, + directives: w.directives, + state$: w.state$, + stores: w.stores, +}); + +export type WidgetState}> = T extends {state$: SubscribableStore} ? U : never; +export type WidgetProps void}> = T extends {patch: (arg: Partial) => void} ? U : never; +export type WidgetFactory = (props?: PropsConfig>) => W; + +export type Directive = (node: HTMLElement, args: T) => void | {update?: (args: T) => void; destroy?: () => void}; + +export type SlotContent = undefined | null | string | ((props: Props) => string); diff --git a/core/lib/utils.ts b/core/lib/utils.ts new file mode 100644 index 0000000000..bff0606f01 --- /dev/null +++ b/core/lib/utils.ts @@ -0,0 +1,2 @@ +export const noop = () => {}; +export const identity = (x: T) => x; diff --git a/core/package.json b/core/package.json new file mode 100644 index 0000000000..7efafaf68e --- /dev/null +++ b/core/package.json @@ -0,0 +1,41 @@ +{ + "name": "@agnos-ui/core", + "description": "Framework-agnostic headless widget library.", + "homepage": "https://amadeusitgroup.github.io/AgnosUI/latest/", + "keywords": [ + "headless", + "agnostic", + "components", + "widgets", + "alert", + "modal", + "pagination", + "rating" + ], + "main": "dist/lib/index.js", + "module": "dist/lib/index.mjs", + "types": "dist/lib/index.d.ts", + "scripts": { + "build": "npm run build:rollup && npm run build:dts && npm run build:api-extractor", + "build:rollup": "tsc && vite build -c vite.config.ts", + "build:dts": "tsc -p tsconfig.d.json", + "build:api-extractor": "api-extractor run", + "test": "vitest run", + "tdd": "vitest", + "tdd:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@amadeus-it-group/tansu": "0.0.22" + }, + "files": [ + "dist/lib" + ], + "license": "MIT", + "bugs": "https://github.com/AmadeusITGroup/AgnosUI/issues", + "repository": { + "type": "git", + "url": "https://github.com/AmadeusITGroup/AgnosUI.git", + "directory": "core" + } +} diff --git a/core/tsconfig.d.json b/core/tsconfig.d.json new file mode 100644 index 0000000000..a44007ce67 --- /dev/null +++ b/core/tsconfig.d.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist/dts" + }, + "include": ["lib"], + "exclude": ["**/*.spec.ts", "**/__mocks__/**"] +} diff --git a/core/tsconfig.json b/core/tsconfig.json new file mode 100644 index 0000000000..7a81bccf77 --- /dev/null +++ b/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "exclude": ["dist"] +} diff --git a/core/vite.config.ts b/core/vite.config.ts new file mode 100644 index 0000000000..675b3954a2 --- /dev/null +++ b/core/vite.config.ts @@ -0,0 +1,26 @@ +import path from 'path'; +import {defineConfig} from 'vite'; +import {alias} from '../viteAlias'; +import {dependencies} from './package.json'; + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + port: 3000, + }, + build: { + lib: { + entry: 'lib/index', + fileName: 'index', + formats: ['es', 'cjs'], + }, + rollupOptions: { + external: Object.keys(dependencies), + }, + emptyOutDir: true, + outDir: path.join(__dirname, 'dist/lib'), + }, + resolve: { + alias, + }, +}); diff --git a/core/vitest.config.ts b/core/vitest.config.ts new file mode 100644 index 0000000000..854d300069 --- /dev/null +++ b/core/vitest.config.ts @@ -0,0 +1,13 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['./**/*.spec.ts'], + coverage: { + reporter: ['text', 'json', 'html', 'lcov'], + src: ['.'], + exclude: ['**/*.spec.ts', '**/__mocks__/**'], + }, + environment: 'happy-dom', + }, +}); diff --git a/demo/.eslintrc.json b/demo/.eslintrc.json new file mode 100644 index 0000000000..b2e037f037 --- /dev/null +++ b/demo/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "extends": ["../.eslintrc.json", "plugin:svelte/recommended", "plugin:svelte/prettier"], + "plugins": ["svelte"], + "parserOptions": { + "project": ["demo/tsconfig.json"], + "extraFileExtensions": [".svelte"] + }, + "overrides": [ + { + "files": ["*.svelte"], + "parserOptions": { + "parser": "@typescript-eslint/parser" + } + } + ] +} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000000..dfa3b12c84 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,25 @@ +{ + "name": "@agnos-ui/demo", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev -c vite.config.ts", + "build": "npm run build:demo && npm run svelte-check", + "build:demo": "vite build -c vite.config.ts", + "preview": "vite preview -c vite.config.ts", + "svelte-check": "svelte-check" + }, + "dependencies": { + "highlight.js": "^11.8.0" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^2.0.2", + "@sveltejs/kit": "^1.21.0", + "@sveltejs/vite-plugin-svelte": "^2.4.2", + "bootstrap-icons": "^1.10.5", + "eslint-plugin-svelte": "^2.32.2", + "prettier-plugin-svelte": "^2.10.1", + "svelte": "^4.0.1", + "svelte-check": "^3.4.4" + } +} diff --git a/demo/scripts/copy.mjs b/demo/scripts/copy.mjs new file mode 100644 index 0000000000..d1189bba9b --- /dev/null +++ b/demo/scripts/copy.mjs @@ -0,0 +1,15 @@ +import * as path from 'path'; +import * as fs from 'fs/promises'; +import {fileURLToPath} from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function copyToDemo(framework) { + const src = path.join(__dirname, `../../${framework}/dist/demo/`); + const dst = path.join(__dirname, `../dist/${framework}/samples/`); + + await fs.rm(dst, {recursive: true, force: true}); + await fs.cp(src, dst, {recursive: true}); +} + +copyToDemo(process.argv[2]); diff --git a/demo/scripts/doc.plugin.ts b/demo/scripts/doc.plugin.ts new file mode 100644 index 0000000000..278c57a82f --- /dev/null +++ b/demo/scripts/doc.plugin.ts @@ -0,0 +1,18 @@ +import type {Plugin} from 'vite'; + +export const docExtractor = (): Plugin => { + return { + name: 'my-api', + transform: { + order: 'pre', + handler(code: string, id: string) { + if (id.endsWith('&extractApi')) { + const widgetLowerCase = id.split('?')[1].split('&')[0].toLowerCase(); + // const widgetName = widgetLowerCase.at(0)?.toUpperCase() + widgetLowerCase.substring(1); + const doc = JSON.parse(code); + return JSON.stringify(doc.widgets[widgetLowerCase]); + } + }, + }, + }; +}; diff --git a/demo/src/app.d.ts b/demo/src/app.d.ts new file mode 100644 index 0000000000..f59b884c51 --- /dev/null +++ b/demo/src/app.d.ts @@ -0,0 +1,12 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface Platform {} + } +} + +export {}; diff --git a/demo/src/app.html b/demo/src/app.html new file mode 100644 index 0000000000..684add75b6 --- /dev/null +++ b/demo/src/app.html @@ -0,0 +1,14 @@ + + + + + + + + %sveltekit.head% + AgnosUI + + +
    %sveltekit.body%
    + + diff --git a/demo/src/app.ts b/demo/src/app.ts new file mode 100644 index 0000000000..ea16b3b612 --- /dev/null +++ b/demo/src/app.ts @@ -0,0 +1,22 @@ +export function getTitle(title: string, frameworkName = '') { + return `AgnosUI - ${title}` + (frameworkName ? ` for ${frameworkName}` : ''); +} + +export function getWidgetDescription(name: string, frameworkName = '') { + return `${name} widget of AgnosUI` + (frameworkName ? ` for ${frameworkName}` : ''); +} + +/** + * Split all the lines of a text, so that it can be easily used in a loop + * @param text text to split + */ +export function textToLines(text: string) { + return text.replaceAll('\r', '').split('\n\n'); +} + +const arrowFunctionRegExp = /^\([^(]*\)[^=]*=>/; +const functionRegExp = /^function/; +const slotRegExp = /^Slot/; +export function normalizedType(type: string) { + return arrowFunctionRegExp.test(type) || functionRegExp.test(type) ? 'function' : slotRegExp.test(type) ? 'slot' : type; +} diff --git a/demo/src/extractApi.d.ts b/demo/src/extractApi.d.ts new file mode 100644 index 0000000000..b6795321ca --- /dev/null +++ b/demo/src/extractApi.d.ts @@ -0,0 +1,6 @@ +declare module '*&extractApi' { + import type {WidgetDoc} from '@agnos-ui/doc/types'; + + const props: WidgetDoc; + export default props; +} diff --git a/demo/src/lib/icons/code.svg b/demo/src/lib/icons/code.svg new file mode 100644 index 0000000000..079f5c67f1 --- /dev/null +++ b/demo/src/lib/icons/code.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/demo/src/lib/icons/open-link.svg b/demo/src/lib/icons/open-link.svg new file mode 100644 index 0000000000..891861172d --- /dev/null +++ b/demo/src/lib/icons/open-link.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/demo/src/lib/layout/Code.svelte b/demo/src/lib/layout/Code.svelte new file mode 100644 index 0000000000..1696630444 --- /dev/null +++ b/demo/src/lib/layout/Code.svelte @@ -0,0 +1,56 @@ + + +
    + {#if isSample} +
    +
    {codeTitle}
    + +
    + {/if} +
    {@html formattedCode}
    +
    + + diff --git a/demo/src/lib/layout/ComponentPage.svelte b/demo/src/lib/layout/ComponentPage.svelte new file mode 100644 index 0000000000..d6dec42b19 --- /dev/null +++ b/demo/src/lib/layout/ComponentPage.svelte @@ -0,0 +1,19 @@ + + + + {getTitle(title, $selectedFramework$)} + + + +
    + diff --git a/demo/src/lib/layout/Header.svelte b/demo/src/lib/layout/Header.svelte new file mode 100644 index 0000000000..04f49f9cd0 --- /dev/null +++ b/demo/src/lib/layout/Header.svelte @@ -0,0 +1,50 @@ + + +
    + + +
    diff --git a/demo/src/lib/layout/Lazy.svelte b/demo/src/lib/layout/Lazy.svelte new file mode 100644 index 0000000000..c3052b5db2 --- /dev/null +++ b/demo/src/lib/layout/Lazy.svelte @@ -0,0 +1,12 @@ + + +{#await promise} + +{:then resolvedComponent} + +{:catch} + +{/await} diff --git a/demo/src/lib/layout/Sample.svelte b/demo/src/lib/layout/Sample.svelte new file mode 100644 index 0000000000..14d67c467a --- /dev/null +++ b/demo/src/lib/layout/Sample.svelte @@ -0,0 +1,167 @@ + + + + +
    + +
    +
    +