├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── angular.json ├── demo.png ├── package-lock.json ├── package.json ├── projects ├── demo │ ├── .eslintrc.json │ ├── src │ │ ├── app │ │ │ ├── app.component.css │ │ │ ├── app.component.html │ │ │ ├── app.component.ts │ │ │ └── app.module.ts │ │ ├── assets │ │ │ └── angular.svg │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ └── styles.css │ └── tsconfig.app.json └── lib │ ├── .eslintrc.json │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── core │ │ ├── foldable.module.ts │ │ ├── if-span.directive.spec.ts │ │ ├── if-span.directive.ts │ │ ├── index.ts │ │ ├── media-queries.ts │ │ ├── screen-context.ts │ │ ├── screen-spanning.ts │ │ ├── split-layout.directive.spec.ts │ │ ├── split-layout.directive.ts │ │ ├── window.directive.spec.ts │ │ └── window.directive.ts │ └── public-api.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "tsconfig.json" 14 | ], 15 | "createDefaultProgram": true 16 | }, 17 | "extends": [ 18 | "plugin:@angular-eslint/ng-cli-compat", 19 | "plugin:@angular-eslint/ng-cli-compat--formatting-add-on", 20 | "plugin:@angular-eslint/template/process-inline-templates" 21 | ], 22 | "rules": {} 23 | }, 24 | { 25 | "files": [ 26 | "*.html" 27 | ], 28 | "extends": [ 29 | "plugin:@angular-eslint/template/recommended" 30 | ], 31 | "rules": {} 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | platform: [ubuntu-latest] 15 | node-version: ['14', '>=18'] 16 | 17 | name: ${{ matrix.platform }} / Node.js v${{ matrix.node-version }} 18 | runs-on: ${{ matrix.platform }} 19 | steps: 20 | - run: git config --global core.autocrlf false # Preserve line endings 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: | 26 | npm i -g npm@latest 27 | npm ci 28 | npm run test:ci 29 | env: 30 | CI: true 31 | 32 | build_all: 33 | if: always() 34 | runs-on: ubuntu-latest 35 | needs: build 36 | steps: 37 | - name: Check build matrix status 38 | if: ${{ needs.build.result != 'success' }} 39 | run: exit 1 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | workflow_dispatch: 4 | repository_dispatch: 5 | types: [release] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | if: github.ref == 'refs/heads/main' 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | persist-credentials: false 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | - run: | 19 | npm ci 20 | npm run build:lib 21 | npm run build:demo 22 | npm run build:docs 23 | npm run build:lib 24 | env: 25 | CI: true 26 | - run: npx semantic-release 27 | if: success() 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | # Need owner/admin account to bypass branch protection 32 | GIT_COMMITTER_NAME: sinedied 33 | GIT_COMMITTER_EMAIL: noda@free.fr 34 | - uses: peaceiris/actions-gh-pages@v3 35 | with: 36 | personal_token: ${{ secrets.GH_TOKEN }} 37 | publish_dir: ./dist/docs 38 | user_name: sinedied 39 | user_email: noda@free.fr 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | .angular/ 48 | 49 | # Files copied from / 50 | /projects/lib/README.md 51 | /projects/lib/LICENSE -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [5.0.0](https://github.com/sinedied/ngx-foldable/compare/4.0.0...5.0.0) (2022-11-29) 2 | 3 | 4 | ### Features 5 | 6 | * update to Angular 15 ([4cb5766](https://github.com/sinedied/ngx-foldable/commit/4cb5766e6cccb2fd04c35df56bd208c1f5a39cfe)) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * Need Angular version >=15 12 | Need Node.js version >=14.20.0 13 | 14 | # [4.0.0](https://github.com/sinedied/ngx-foldable/compare/3.1.0...4.0.0) (2022-06-03) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * set minimum Angular version to 14 ([26533ab](https://github.com/sinedied/ngx-foldable/commit/26533ab46527b35385cb9ff89382014ca7f361ff)) 20 | * set minimum node version ([7527356](https://github.com/sinedied/ngx-foldable/commit/7527356cf5b6d6787462a72e6c6077d9d433a6e7)) 21 | 22 | 23 | ### BREAKING CHANGES 24 | 25 | * set minimum node version to 14.15 26 | * set minimum Angular version to 14 27 | 28 | # [3.1.0](https://github.com/sinedied/ngx-foldable/compare/3.0.0...3.1.0) (2022-06-03) 29 | 30 | 31 | ### Features 32 | 33 | * support new stable API ([30b605d](https://github.com/sinedied/ngx-foldable/commit/30b605d4918ca41a09b3537be16acd25a70d1a91)) 34 | 35 | # [3.0.0](https://github.com/sinedied/ngx-foldable/compare/2.0.0...3.0.0) (2021-11-25) 36 | 37 | 38 | ### Features 39 | 40 | * rename spanning mode and media queries ([66c7a62](https://github.com/sinedied/ngx-foldable/commit/66c7a62a93fa890ea8dc2b6f7a03b88253251116)) 41 | * upgrade to latest APF ([5ac1ca9](https://github.com/sinedied/ngx-foldable/commit/5ac1ca9c4bc6ce57af6c79e1c4a395f40f18628c)) 42 | 43 | 44 | ### BREAKING CHANGES 45 | 46 | * This library now requires Angular v13 or latest. 47 | Use the previous versions of this library if you require compatibility with older Angular version. 48 | * To better match the new viewport browser API, the spanning modes and media queries have been renamed. 49 | It now matches the number of segments on a given axis instead of the fold axis, 50 | which should be less confusing. 51 | 52 | You can migrate by performing these replacements: 53 | - ScreenSpanning.Vertical -> ScreenSpanning.DualHorizontal 54 | - ScreenSpanning.Horizontal -> ScreenSpanning.DualVertical 55 | - 'fold-horizontal' -> 'dual-vertical' 56 | - 'fold-vertical' -> 'dual-horizontal' 57 | - singleFoldHorizontal -> dualVerticalViewport 58 | - singleFoldVertical -> dualHorizontalViewport 59 | 60 | # [2.0.0](https://github.com/sinedied/ngx-foldable/compare/1.1.1...2.0.0) (2021-11-22) 61 | 62 | 63 | ### Features 64 | 65 | * remove old APIs support ([df1ca39](https://github.com/sinedied/ngx-foldable/commit/df1ca390eae33279ef4ba145da6a5653c0231cfe)) 66 | * update CSS for new APIs ([afab1c4](https://github.com/sinedied/ngx-foldable/commit/afab1c486462f9f2b3f36929f50c2c7df56d34c8)) 67 | * update media queries to support new css segments API ([fc26aae](https://github.com/sinedied/ngx-foldable/commit/fc26aae9ab035715c8e429cc4aee5b7250f9b19f)) 68 | * update screenContext to support new visualViewport API ([a444236](https://github.com/sinedied/ngx-foldable/commit/a444236028c6d301e753ef9fb3f798f1108d20bd)) 69 | 70 | 71 | ### BREAKING CHANGES 72 | 73 | * remove old APIs support 74 | 75 | ## [1.1.1](https://github.com/sinedied/ngx-foldable/compare/1.1.0...1.1.1) (2021-03-19) 76 | 77 | 78 | ### Bug Fixes 79 | 80 | * layout issues on real devices ([8446001](https://github.com/sinedied/ngx-foldable/commit/844600128fb3af4760b577ef22299842b6f318d7)) 81 | 82 | # [1.1.0](https://github.com/sinedied/ngx-foldable/compare/1.0.5...1.1.0) (2021-03-17) 83 | 84 | 85 | ### Bug Fixes 86 | 87 | * inconsistent layout with rtl and fdIfSpan ([1acd46e](https://github.com/sinedied/ngx-foldable/commit/1acd46e32c237624d2e436c6bc1625cad3035f29)) 88 | * only allow valid options for SplitLayoutDirective ([3240712](https://github.com/sinedied/ngx-foldable/commit/3240712f21dbd25347cdf69c8f4b53daee340dd7)) 89 | 90 | 91 | ### Features 92 | 93 | * add option to reverse window order ([acb3fff](https://github.com/sinedied/ngx-foldable/commit/acb3fff202be180639e2cffcf3e1483e1547d6c0)) 94 | 95 | ## [1.0.5](https://github.com/sinedied/ngx-foldable/compare/1.0.4...1.0.5) (2021-03-16) 96 | 97 | 98 | ### Bug Fixes 99 | 100 | * refresh on orientation changes and extra repaints ([0683aa3](https://github.com/sinedied/ngx-foldable/commit/0683aa348d992aa23ed2778d9b65f4bf5b95a44c)) 101 | * screen context initialization ([14da071](https://github.com/sinedied/ngx-foldable/commit/14da07174867fcda1b4c3919907250b9ca89f8ca)) 102 | 103 | ## [1.0.4](https://github.com/sinedied/ngx-foldable/compare/1.0.3...1.0.4) (2021-03-16) 104 | 105 | 106 | ### Bug Fixes 107 | 108 | * issues when using typescript strict mode ([b84fc9f](https://github.com/sinedied/ngx-foldable/commit/b84fc9f86a0c02bd71fa072f8be5ca1e63db90fb)) 109 | 110 | ## [1.0.3](https://github.com/sinedied/ngx-foldable/compare/1.0.2...1.0.3) (2021-03-12) 111 | 112 | 113 | ### Bug Fixes 114 | 115 | * update min angular version ([d383609](https://github.com/sinedied/ngx-foldable/commit/d3836093a9a5eee19bead640062200bb1994d807)) 116 | 117 | ## [1.0.2](https://github.com/sinedied/ngx-foldable/compare/1.0.1...1.0.2) (2021-03-12) 118 | 119 | 120 | ### Bug Fixes 121 | 122 | * angular min version ([4aa85c7](https://github.com/sinedied/ngx-foldable/commit/4aa85c78f57c9f817b0a3efa61372340dca58b99)) 123 | 124 | ## [1.0.1](https://github.com/sinedied/ngx-foldable/compare/1.0.0...1.0.1) (2021-03-11) 125 | 126 | 127 | ### Bug Fixes 128 | 129 | * docs deployment ([b1c68ac](https://github.com/sinedied/ngx-foldable/commit/b1c68ac7641f2145addef1480f5e669207a349a5)) 130 | 131 | # 1.0.0 (2021-03-11) 132 | 133 | 134 | ### Bug Fixes 135 | 136 | * directives export ([536764f](https://github.com/sinedied/ngx-foldable/commit/536764fd1c959501de1a25469281c9fb2537dfeb)) 137 | * fdIfSpan init ([7e66b70](https://github.com/sinedied/ngx-foldable/commit/7e66b70c71a84e784eb60cb56744e9c1b9d9a8c3)) 138 | * revert naming changes ([3ee5543](https://github.com/sinedied/ngx-foldable/commit/3ee55430887e815dec7ecfd4b5e587f2cdd1abc4)) 139 | 140 | 141 | ### Features 142 | 143 | * add RTL support ([8bdb155](https://github.com/sinedied/ngx-foldable/commit/8bdb1554fe44304bda979dc7c184c91df3ded32e)) 144 | * add ScreenContext first value and fix span check ([487885f](https://github.com/sinedied/ngx-foldable/commit/487885f02fe3b68141f68382fd3c2318748211a1)) 145 | * add SplitLayout directive ([9fadf70](https://github.com/sinedied/ngx-foldable/commit/9fadf702882a53c57f3d8440074f8f3feca82ffe)) 146 | * add support for grid and absolute layouts ([582e83e](https://github.com/sinedied/ngx-foldable/commit/582e83eb4176ccb9f9a602c835522ad8f70095df)) 147 | * add Window directive ([b2a3632](https://github.com/sinedied/ngx-foldable/commit/b2a3632fa2f950e9b0b3cd237540857e319d8beb)) 148 | * demo test ([90ad184](https://github.com/sinedied/ngx-foldable/commit/90ad1844ea8bcbf50c0e7c892ae61635f7b2b993)) 149 | * finish demo project ([db13ef1](https://github.com/sinedied/ngx-foldable/commit/db13ef1f6d798c5716dc61bf6f3d60fdb9901c0d)) 150 | * implement fdIfSpan directive ([083b648](https://github.com/sinedied/ngx-foldable/commit/083b64890e57b451c42ab42d930ca30a6d2c22e1)) 151 | * implement ScreenContext service ([466a9d7](https://github.com/sinedied/ngx-foldable/commit/466a9d7d6f257eccf6590381fc19815dead14e0f)) 152 | * initial commit ([9ff473a](https://github.com/sinedied/ngx-foldable/commit/9ff473a1c34bb6be4b3185bb92fd4e0a0fcee7f7)) 153 | * initial work on service API ([962620d](https://github.com/sinedied/ngx-foldable/commit/962620d0bd0a9c731e695d5f540e12e3dc9331b4)) 154 | * update to ng 11 ([4708aff](https://github.com/sinedied/ngx-foldable/commit/4708aff57cf290991aa6bf7ef77bc2768614847c)) 155 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect everyone who contributes by posting issues, updating documentation, submitting pull requests, providing feedback in comments, and any other activities. 4 | 5 | Communication through any of this project's channels (GitHub, Slack, IRC, mailing lists, Twitter, etc.) must be constructive and never resort to personal attacks, trolling, public or private harassment, insults, or other disrectpectful conduct. 6 | 7 | We promise to extend courtesy and respect to everyone involved in this project regardless of gender, gender identity, sexual orientation, disability, age, race, ethnicity, religion, or level of experience. We expect anyone contributing to this project to do the same. 8 | 9 | If any member of the community violates this code of conduct, the maintainers of this project will take action, removing issues, comments, and PRs or blocking accounts as deemed appropriate. 10 | 11 | If you are subject to or witness unacceptable behavior, or have any other concerns, please contact us using any of this project's channels. 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Yohan Lasorsa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📖 ngx-foldable 2 | 3 | [![NPM version](https://img.shields.io/npm/v/ngx-foldable.svg)](https://www.npmjs.com/package/ngx-foldable) 4 | ![Node version](https://img.shields.io/node/v/ngx-foldable.svg) 5 | ![Angular version](https://img.shields.io/badge/angular-%3E%3D15-dd0031?logo=Angular) 6 | [![Build Status](https://github.com/sinedied/ngx-foldable/workflows/build/badge.svg)](https://github.com/sinedied/ngx-foldable/actions) 7 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 8 | 9 | > ngx-foldable is a set of components and services to help you build dual-screen experiences for foldable or dual-screen devices, such as the [Surface Duo](https://docs.microsoft.com/dual-screen/web/?WT.mc_id=javascript-9776-yolasors) 10 | 11 |

12 | 13 |

14 | 15 | See the [live demo](https://sinedied.github.io/ngx-foldable/demo/) or read the [full documentation](https://sinedied.github.io/ngx-foldable/). 16 | 17 | ## How to test on your browser 18 | 19 | The dual-screen emulation feature requires latest Microsoft Edge or Google Chrome versions (>= 97). 20 | 21 | If you have older browser versions, you need to enable experimental flags. 22 | Follow [the instructions here](https://devblogs.microsoft.com/surface-duo/build-and-test-dual-screen-web-apps/?WT.mc_id=javascript-9776-yolasors#build-and-test-on-the-desktop) to setup your browser for dual-screen emulation. 23 | 24 | ## Library usage 25 | 26 | Check out the [demo](./projects/demo/src/app) source code to see an example usage of the library. 27 | 28 | Add the library to your Angular project: 29 | 30 | ```sh 31 | npm install ngx-foldable 32 | ``` 33 | 34 | Import the library in your app: 35 | 36 | ```ts 37 | import { FoldableModule } from 'ngx-foldable'; 38 | ... 39 | 40 | @NgModule({ 41 | ... 42 | imports: [ 43 | FoldableModule 44 | ... 45 | ], 46 | ... 47 | }) 48 | export class AppModule { } 49 | ``` 50 | 51 | Use the provided `fdSplitLayout`, `fdWindow` and `fdIfSpan` directives to build your layout: 52 | 53 | ```html 54 | 58 |

59 | 60 | 61 |
62 | This will be displayed on the first window segment of a multi screen or single screen device. 63 | 64 |

This is only visible on a single screen device.

65 |

This is only visible on a multi screen device.

66 |
67 | 68 | 69 |
70 | This will be displayed on the second window segment of a multi screen device. 71 | 72 |

This is only visible on multi screen device, regardless of the orientation.

73 |

This is only visible on dual vertical viewports.

74 |

This is only visible on dual horizontal viewports.

75 |
76 |
77 | ``` 78 | 79 | Using the `ScreenContext` service, you can also receive updates when the screen context changes: 80 | 81 | ```typescript 82 | import { ScreenContext } from 'ngx-foldable'; 83 | ... 84 | export class AppComponent { 85 | constructor(private screenContext: ScreenContext) { 86 | this.screenContext 87 | .asObservable() 88 | .subscribe((context) => { 89 | console.log('Screen context changed:', context); 90 | }); 91 | } 92 | } 93 | ``` 94 | 95 | You can read the full documentation [here](https://sinedied.github.io/ngx-foldable/). 96 | 97 | ## Contributing 98 | 99 | You're welcome to contribute to this project! 100 | Make sure you have read the [code of conduct](./CODE_OF_CONDUCT) before posting an issue or a pull request. 101 | 102 | Follow these steps to run this project locally: 103 | 104 | 1. Clone the repository on your machine 105 | 2. Run `npm install` to install packages 106 | 3. Run `npm start` to start the dev server with the demo app 107 | 108 | You can then start making modifications on the library or demo app code. 109 | 110 | ## Related work 111 | 112 | - [Surface Duo Photo Gallery](https://github.com/sinedied/surface-duo-photo-gallery): Angular re-implementation of the [Surface Duo Photo Gallery sample](https://github.com/foldable-devices/demos/tree/master/photo-gallery) using this library 113 | - [react-foldable](https://github.com/aaronpowell/react-foldable): a similar library built for React 114 | - [foldable-devices/demos](https://github.com/foldable-devices/demos): web demos for foldables devices. 115 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngx-foldable": { 7 | "projectType": "library", 8 | "root": "projects/lib", 9 | "sourceRoot": "projects/lib/src", 10 | "prefix": "fd", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "project": "projects/lib/ng-package.json" 16 | }, 17 | "configurations": { 18 | "production": { 19 | "tsConfig": "projects/lib/tsconfig.lib.prod.json" 20 | }, 21 | "development": { 22 | "tsConfig": "projects/lib/tsconfig.lib.json" 23 | } 24 | }, 25 | "defaultConfiguration": "production" 26 | }, 27 | "test": { 28 | "builder": "@angular-devkit/build-angular:karma", 29 | "options": { 30 | "tsConfig": "projects/lib/tsconfig.spec.json", 31 | "polyfills": [ 32 | "zone.js", 33 | "zone.js/testing" 34 | ] 35 | } 36 | }, 37 | "lint": { 38 | "builder": "@angular-eslint/builder:lint", 39 | "options": { 40 | "lintFilePatterns": [ 41 | "projects/lib/**/*.ts", 42 | "projects/lib/**/*.html" 43 | ] 44 | } 45 | } 46 | } 47 | }, 48 | "demo": { 49 | "projectType": "application", 50 | "schematics": { 51 | "@schematics/angular:component": { 52 | "inlineTemplate": true, 53 | "inlineStyle": true, 54 | "skipTests": true 55 | }, 56 | "@schematics/angular:class": { 57 | "skipTests": true 58 | }, 59 | "@schematics/angular:directive": { 60 | "skipTests": true 61 | }, 62 | "@schematics/angular:guard": { 63 | "skipTests": true 64 | }, 65 | "@schematics/angular:interceptor": { 66 | "skipTests": true 67 | }, 68 | "@schematics/angular:module": { 69 | "skipTests": true 70 | }, 71 | "@schematics/angular:pipe": { 72 | "skipTests": true 73 | }, 74 | "@schematics/angular:service": { 75 | "skipTests": true 76 | } 77 | }, 78 | "root": "projects/demo", 79 | "sourceRoot": "projects/demo/src", 80 | "prefix": "app", 81 | "architect": { 82 | "build": { 83 | "builder": "@angular-devkit/build-angular:browser", 84 | "options": { 85 | "outputPath": "dist/demo", 86 | "index": "projects/demo/src/index.html", 87 | "main": "projects/demo/src/main.ts", 88 | "polyfills": [ 89 | "zone.js" 90 | ], 91 | "tsConfig": "projects/demo/tsconfig.app.json", 92 | "inlineStyleLanguage": "sass", 93 | "assets": [ 94 | "projects/demo/src/favicon.ico", 95 | "projects/demo/src/assets" 96 | ], 97 | "styles": [ 98 | "projects/demo/src/styles.css" 99 | ], 100 | "scripts": [] 101 | }, 102 | "configurations": { 103 | "production": { 104 | "budgets": [ 105 | { 106 | "type": "initial", 107 | "maximumWarning": "2mb", 108 | "maximumError": "5mb" 109 | }, 110 | { 111 | "type": "anyComponentStyle", 112 | "maximumWarning": "6kb", 113 | "maximumError": "10kb" 114 | } 115 | ], 116 | "outputHashing": "all" 117 | }, 118 | "development": { 119 | "buildOptimizer": false, 120 | "optimization": false, 121 | "vendorChunk": true, 122 | "extractLicenses": false, 123 | "sourceMap": true, 124 | "namedChunks": true 125 | } 126 | }, 127 | "defaultConfiguration": "production" 128 | }, 129 | "serve": { 130 | "builder": "@angular-devkit/build-angular:dev-server", 131 | "options": { 132 | "browserTarget": "demo:build" 133 | }, 134 | "configurations": { 135 | "production": { 136 | "browserTarget": "demo:build:production" 137 | }, 138 | "development": { 139 | "browserTarget": "demo:build:development" 140 | } 141 | }, 142 | "defaultConfiguration": "development" 143 | }, 144 | "extract-i18n": { 145 | "builder": "@angular-devkit/build-angular:extract-i18n", 146 | "options": { 147 | "browserTarget": "demo:build" 148 | } 149 | }, 150 | "lint": { 151 | "builder": "@angular-eslint/builder:lint", 152 | "options": { 153 | "lintFilePatterns": [ 154 | "projects/demo/**/*.ts", 155 | "projects/demo/**/*.html" 156 | ] 157 | } 158 | } 159 | } 160 | } 161 | }, 162 | "cli": { 163 | "analytics": false 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/ngx-foldable/8c1095f2c9e346ddf3b816611282edf5e7a9a57d/demo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-foldable", 3 | "version": "0.0.0", 4 | "description": "Angular library to help you build dual-screen experiences for foldable or dual-screen devices", 5 | "private": true, 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "ng serve demo", 9 | "build:lib": "npm run -s copy:assets && ng build ngx-foldable --configuration production", 10 | "build:demo": "ng build --configuration production demo --base-href='/ngx-foldable/demo/'", 11 | "build:docs": "compodoc -p projects/lib/tsconfig.lib.json projects/lib/src/core -d dist/docs && cp -R dist/demo dist/docs/", 12 | "deploy:docs": "gh-pages -d dist/docs", 13 | "test": "ng test ngx-foldable", 14 | "test:ci": "ng lint ngx-foldable && ng test ngx-foldable --browsers=ChromeHeadless --watch false", 15 | "lint": "ng lint ngx-foldable", 16 | "copy:assets": "cp README.md LICENSE projects/lib/", 17 | "serve:docs": "compodoc -p projects/lib/tsconfig.lib.json projects/lib/src/core -d dist/docs -s -w -r 4210 -o --disable-internal", 18 | "release:check": "semantic-release --dry-run" 19 | }, 20 | "keywords": [ 21 | "angular", 22 | "foldable", 23 | "library", 24 | "surface", 25 | "multi-screen", 26 | "dual-screen", 27 | "components", 28 | "helper" 29 | ], 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/sinedied/ngx-foldable.git" 33 | }, 34 | "author": { 35 | "name": "Yohan Lasorsa", 36 | "url": "https://twitter.com/sinedied" 37 | }, 38 | "homepage": "https://github.com/sinedied/ngx-foldable", 39 | "bugs": { 40 | "url": "https://github.com/sinedied/ngx-foldable/issues" 41 | }, 42 | "license": "MIT", 43 | "dependencies": { 44 | "@angular/animations": "^15.2.6", 45 | "@angular/common": "^15.2.6", 46 | "@angular/compiler": "^15.2.6", 47 | "@angular/core": "^15.2.6", 48 | "@angular/forms": "^15.2.6", 49 | "@angular/platform-browser": "^15.2.6", 50 | "@angular/platform-browser-dynamic": "^15.2.6", 51 | "@angular/router": "^15.2.6", 52 | "rxjs": "^7.5.5", 53 | "tslib": "^2.3.0", 54 | "zone.js": "~0.11.4" 55 | }, 56 | "devDependencies": { 57 | "@angular-devkit/build-angular": "^15.2.5", 58 | "@angular-eslint/builder": "^15.1.0", 59 | "@angular-eslint/eslint-plugin": "^15.1.0", 60 | "@angular-eslint/eslint-plugin-template": "^15.1.0", 61 | "@angular-eslint/schematics": "^15.1.0", 62 | "@angular-eslint/template-parser": "^15.1.0", 63 | "@angular/cli": "^15.2.5", 64 | "@angular/compiler-cli": "^15.2.6", 65 | "@compodoc/compodoc": "^1.1.11", 66 | "@types/jasmine": "^4.0.3", 67 | "@types/jasminewd2": "~2.0.9", 68 | "@types/node": "^14.18.42", 69 | "@typescript-eslint/eslint-plugin": "^5.4.0", 70 | "@typescript-eslint/parser": "^5.4.0", 71 | "eslint": "^8.3.0", 72 | "eslint-plugin-import": "^2.25.3", 73 | "eslint-plugin-jsdoc": "^40.1.1", 74 | "eslint-plugin-prefer-arrow": "^1.2.3", 75 | "gh-pages": "^5.0.0", 76 | "jasmine-core": "^4.1.1", 77 | "karma": "^6.4.1", 78 | "karma-chrome-launcher": "~3.1.0", 79 | "karma-coverage": "~2.2.0", 80 | "karma-jasmine": "^5.0.1", 81 | "karma-jasmine-html-reporter": "^2.0.0", 82 | "ng-packagr": "^15.0.1", 83 | "semantic-release-npm-github": "^4.0.0", 84 | "typescript": "~4.8.4" 85 | }, 86 | "engines": { 87 | "node": ">=14.20.0" 88 | }, 89 | "compodoc": { 90 | "hideGenerator": true, 91 | "disableInternal": true, 92 | "disablePrivate": true, 93 | "disableCoverage": true, 94 | "disableDependencies": true 95 | }, 96 | "release": { 97 | "extends": "semantic-release-npm-github", 98 | "plugins": [ 99 | [ 100 | "@semantic-release/commit-analyzer", 101 | { 102 | "releaseRules": [ 103 | { 104 | "type": "chore", 105 | "release": "patch" 106 | }, 107 | { 108 | "type": "docs", 109 | "release": "patch" 110 | }, 111 | { 112 | "type": "refactor", 113 | "release": "patch" 114 | }, 115 | { 116 | "type": "style", 117 | "release": "patch" 118 | } 119 | ] 120 | } 121 | ], 122 | "@semantic-release/release-notes-generator", 123 | "@semantic-release/changelog", 124 | [ 125 | "@semantic-release/npm", 126 | { 127 | "pkgRoot": "dist/ngx-foldable", 128 | "tarballDir": ".package" 129 | } 130 | ], 131 | [ 132 | "@semantic-release/git", 133 | { 134 | "assets": [ 135 | "package.json", 136 | "package-lock.json", 137 | "CHANGELOG.md", 138 | "README.md" 139 | ], 140 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 141 | } 142 | ], 143 | [ 144 | "@semantic-release/github", 145 | { 146 | "assets": ".package/*.tgz" 147 | } 148 | ] 149 | ], 150 | "branches": "main" 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /projects/demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": [ 4 | "!**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "projects/demo/tsconfig.app.json" 14 | ], 15 | "createDefaultProgram": true 16 | }, 17 | "rules": { 18 | "@angular-eslint/component-selector": [ 19 | "error", 20 | { 21 | "type": "element", 22 | "prefix": "app", 23 | "style": "kebab-case" 24 | } 25 | ], 26 | "@angular-eslint/directive-selector": [ 27 | "error", 28 | { 29 | "type": "attribute", 30 | "prefix": "app", 31 | "style": "camelCase" 32 | } 33 | ] 34 | } 35 | }, 36 | { 37 | "files": [ 38 | "*.html" 39 | ], 40 | "rules": {} 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.css: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100%; 3 | display: flex; 4 | } 5 | 6 | .main, .secondary { 7 | position: relative; 8 | display: flex; 9 | flex: auto; 10 | flex-direction: column; 11 | align-items: center; 12 | padding: 2em; 13 | overflow: auto; 14 | } 15 | 16 | .main::before { 17 | content: ''; 18 | display: block; 19 | position: absolute; 20 | background-color: #27d; 21 | background: linear-gradient(145deg, #04a, #4af); 22 | top: 0; 23 | height: 200px; 24 | width: 100%; 25 | z-index: -1; 26 | transform: scaleY(2)translateY(-10%)skewY(3deg); 27 | } 28 | 29 | .secondary { 30 | background: linear-gradient(160deg, #4af, #fff 60%); 31 | justify-content: center; 32 | text-align: center; 33 | } 34 | 35 | .logo { 36 | width: 200px; 37 | } 38 | 39 | .note { 40 | border: 1px solid #ccc; 41 | background-color: #f2f2f2; 42 | border-radius: 10px; 43 | padding: 15px; 44 | color: #333; 45 | } 46 | 47 | .surface { 48 | border-radius: 20px; 49 | background-color: #112; 50 | position: relative; 51 | width: 306px; 52 | height: 206px; 53 | border: 3px solid #ccd; 54 | } 55 | 56 | .surface::before { 57 | content: ''; 58 | display: block; 59 | width: 294px; 60 | height: 160px; 61 | top: 20px; 62 | left: 3px; 63 | position: absolute; 64 | background: linear-gradient( 65 | to right, 66 | #eef 144px, 67 | transparent 144px, 68 | transparent 150px, 69 | #eef 150px 70 | ); 71 | } 72 | 73 | .surface::after { 74 | content: ''; 75 | display: block; 76 | border-radius: 50%; 77 | width: 8px; 78 | height: 8px; 79 | position: absolute; 80 | background: #999; 81 | box-shadow: 0 0 0 2px #bbb; 82 | top: 7px; 83 | right: 32px; 84 | } 85 | 86 | .rotated { 87 | transform: rotate(-90deg); 88 | margin: 50px 0; 89 | } 90 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 5 |
6 | 7 | 8 |
9 |

Angular Foldable Demo

10 | 11 |

This little demo showcases how ngx-foldable library can help you build dual-screen experiences for foldable or dual-screen devices.

12 | 13 |

14 | 15 | This app is running on a single screen setup. 16 | 17 | 18 | 19 | This app is running on {{ (screenContext$ | async)?.windowSegments?.length }} screens. 20 | 21 | 22 |

23 |
24 | 25 | 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | 34 |

In multi screen mode you can see your device orientation here.

35 |
36 | 37 |
38 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ScreenContext } from 'ngx-foldable'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrls: ['./app.component.css'] 8 | }) 9 | export class AppComponent { 10 | 11 | screenContext$ = this.screenContext.asObservable(); 12 | 13 | constructor(private screenContext: ScreenContext) {} 14 | 15 | } 16 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { FoldableModule } from 'ngx-foldable'; 4 | 5 | import { AppComponent } from './app.component'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | AppComponent 10 | ], 11 | imports: [ 12 | BrowserModule, 13 | FoldableModule 14 | ], 15 | providers: [], 16 | bootstrap: [AppComponent] 17 | }) 18 | export class AppModule { } 19 | -------------------------------------------------------------------------------- /projects/demo/src/assets/angular.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | 10 | 11 | 12 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /projects/demo/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /projects/demo/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /projects/demo/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sinedied/ngx-foldable/8c1095f2c9e346ddf3b816611282edf5e7a9a57d/projects/demo/src/favicon.ico -------------------------------------------------------------------------------- /projects/demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /projects/demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | 3 | import { AppModule } from './app/app.module'; 4 | 5 | platformBrowserDynamic().bootstrapModule(AppModule) 6 | .catch(err => console.error(err)); 7 | -------------------------------------------------------------------------------- /projects/demo/src/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | height: 100%; 4 | font-family: 'Open Sans', sans-serif; 5 | font-size: 18px; 6 | } 7 | 8 | * { 9 | box-sizing: border-box; 10 | } 11 | 12 | h1, h2 { 13 | font-family: 'Roboto', sans-serif; 14 | } 15 | 16 | h1 { 17 | font-size: 2em; 18 | color: #fff; 19 | } 20 | 21 | h2, a { 22 | font-weight: bold; 23 | color: #27d; 24 | } 25 | 26 | em { 27 | font-weight: bold; 28 | font-style: normal; 29 | color: #fff; 30 | background-color: #445; 31 | border-radius: .7em; 32 | padding: 5px 10px; 33 | margin: 0 5px; 34 | } 35 | -------------------------------------------------------------------------------- /projects/demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/lib/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": [ 4 | "!**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "projects/lib/tsconfig.lib.json", 14 | "projects/lib/tsconfig.spec.json" 15 | ], 16 | "createDefaultProgram": true 17 | }, 18 | "rules": { 19 | "@angular-eslint/component-selector": [ 20 | "error", 21 | { 22 | "type": "element", 23 | "prefix": "fd", 24 | "style": "kebab-case" 25 | } 26 | ], 27 | "@angular-eslint/directive-selector": [ 28 | "error", 29 | { 30 | "type": "attribute", 31 | "prefix": "fd", 32 | "style": "camelCase" 33 | } 34 | ], 35 | "@typescript-eslint/naming-convention": "off", 36 | "@typescript-eslint/member-ordering": "off", 37 | "no-shadow": "off" 38 | } 39 | }, 40 | { 41 | "files": [ 42 | "*.html" 43 | ], 44 | "rules": {} 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /projects/lib/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngx-foldable", 4 | "assets": [ 5 | "README.md", 6 | "LICENSE" 7 | ], 8 | "lib": { 9 | "entryFile": "src/public-api.ts" 10 | } 11 | } -------------------------------------------------------------------------------- /projects/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-foldable", 3 | "version": "0.1.0", 4 | "description": "Angular library to help you build dual-screen experiences for foldable or dual-screen devices", 5 | "scripts": { 6 | "postpublish": "cp package.json ../../projects/lib/" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/sinedied/ngx-foldable.git" 11 | }, 12 | "author": { 13 | "name": "Yohan Lasorsa", 14 | "url": "https://twitter.com/sinedied" 15 | }, 16 | "homepage": "https://github.com/sinedied/ngx-foldable", 17 | "bugs": { 18 | "url": "https://github.com/sinedied/ngx-foldable/issues" 19 | }, 20 | "license": "MIT", 21 | "keywords": [ 22 | "angular", 23 | "foldable", 24 | "library", 25 | "surface", 26 | "multi-screen", 27 | "dual-screen", 28 | "components", 29 | "helper" 30 | ], 31 | "peerDependencies": { 32 | "@angular/common": ">=15", 33 | "@angular/core": ">=15" 34 | }, 35 | "dependencies": { 36 | "tslib": "^2.3.0" 37 | }, 38 | "engines": { 39 | "node": ">=14.20.0" 40 | } 41 | } -------------------------------------------------------------------------------- /projects/lib/src/core/foldable.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { IfSpanDirective } from './if-span.directive'; 3 | import { WindowDirective } from './window.directive'; 4 | import { SplitLayoutDirective } from './split-layout.directive'; 5 | 6 | /** 7 | * This module contains utilities to help you build your app layout for multi 8 | * screen devices. 9 | * 10 | * See {@link SplitLayoutDirective}, 11 | * {@link WindowDirective}, 12 | * {@link IfSpanDirective}, 13 | * {@link ScreenContext} 14 | */ 15 | @NgModule({ 16 | declarations: [IfSpanDirective, WindowDirective, SplitLayoutDirective], 17 | exports: [IfSpanDirective, WindowDirective, SplitLayoutDirective], 18 | }) 19 | export class FoldableModule {} 20 | -------------------------------------------------------------------------------- /projects/lib/src/core/if-span.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component, DebugElement } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { Subject } from 'rxjs'; 5 | import { IfSpanDirective, SpanCondition } from './if-span.directive'; 6 | import { ScreenContext, ScreenContextData } from './screen-context'; 7 | import { ScreenSpanning } from './screen-spanning'; 8 | 9 | @Component({ 10 | template: `
visible
`, 11 | }) 12 | class TestComponent { 13 | condition = SpanCondition.None; 14 | } 15 | 16 | const getter = (obj: any, prop: string): jasmine.Spy => 17 | Object.getOwnPropertyDescriptor(obj, prop)?.get as jasmine.Spy; 18 | 19 | describe('IfSpanDirective', () => { 20 | let component: TestComponent; 21 | let fixture: ComponentFixture; 22 | let screenContextSpy: jasmine.SpyObj; 23 | let el: DebugElement; 24 | 25 | beforeEach(() => { 26 | const fakeObservable$ = new Subject(); 27 | screenContextSpy = jasmine.createSpyObj( 28 | 'ScreenContext', 29 | ['asObservable', 'asObject'], 30 | ['isMultiScreen', 'screenSpanning', 'windowSegments'] 31 | ); 32 | screenContextSpy.asObservable.and.returnValue(fakeObservable$); 33 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(false); 34 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 35 | ScreenSpanning.None 36 | ); 37 | 38 | fixture = TestBed.configureTestingModule({ 39 | declarations: [IfSpanDirective, TestComponent], 40 | providers: [{ provide: ScreenContext, useValue: screenContextSpy }], 41 | }).createComponent(TestComponent); 42 | 43 | component = fixture.componentInstance; 44 | }); 45 | 46 | describe('condition None', () => { 47 | it('should make el visible on single screen mode', () => { 48 | fixture.detectChanges(); 49 | el = fixture.debugElement.query(By.css('div')); 50 | 51 | expect(el).not.toBeNull(); 52 | }); 53 | 54 | it('should make el invisible on multi screen mode', () => { 55 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(true); 56 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 57 | ScreenSpanning.DualHorizontal 58 | ); 59 | fixture.detectChanges(); 60 | el = fixture.debugElement.query(By.css('div')); 61 | 62 | expect(el).toBeNull(); 63 | }); 64 | }); 65 | 66 | describe('condition Multi', () => { 67 | beforeEach(() => { 68 | component.condition = SpanCondition.Multi; 69 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 70 | ScreenSpanning.DualVertical 71 | ); 72 | }); 73 | 74 | it('should make el visible on multi screen mode', () => { 75 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(true); 76 | fixture.detectChanges(); 77 | el = fixture.debugElement.query(By.css('div')); 78 | 79 | expect(el).not.toBeNull(); 80 | }); 81 | 82 | it('should make el invisible on single screen mode', () => { 83 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(false); 84 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 85 | ScreenSpanning.None 86 | ); 87 | fixture.detectChanges(); 88 | el = fixture.debugElement.query(By.css('div')); 89 | 90 | expect(el).toBeNull(); 91 | }); 92 | }); 93 | 94 | describe('condition Horizontal', () => { 95 | beforeEach(() => { 96 | component.condition = SpanCondition.Horizontal; 97 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(true); 98 | }); 99 | 100 | it('should make el visible on horizontal span mode', () => { 101 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 102 | ScreenSpanning.DualVertical 103 | ); 104 | fixture.detectChanges(); 105 | el = fixture.debugElement.query(By.css('div')); 106 | 107 | expect(el).not.toBeNull(); 108 | }); 109 | 110 | it('should make el invisible on vertical span mode', () => { 111 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 112 | ScreenSpanning.DualHorizontal 113 | ); 114 | fixture.detectChanges(); 115 | el = fixture.debugElement.query(By.css('div')); 116 | 117 | expect(el).toBeNull(); 118 | }); 119 | 120 | it('should make el invisible on single screen mode', () => { 121 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(false); 122 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 123 | ScreenSpanning.None 124 | ); 125 | fixture.detectChanges(); 126 | el = fixture.debugElement.query(By.css('div')); 127 | 128 | expect(el).toBeNull(); 129 | }); 130 | }); 131 | 132 | describe('condition Vertical', () => { 133 | beforeEach(() => { 134 | component.condition = SpanCondition.Vertical; 135 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(true); 136 | }); 137 | 138 | it('should make el invisible on horizontal span mode', () => { 139 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 140 | ScreenSpanning.DualVertical 141 | ); 142 | fixture.detectChanges(); 143 | el = fixture.debugElement.query(By.css('div')); 144 | 145 | expect(el).toBeNull(); 146 | }); 147 | 148 | it('should make el visible on vertical span mode', () => { 149 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 150 | ScreenSpanning.DualHorizontal 151 | ); 152 | fixture.detectChanges(); 153 | el = fixture.debugElement.query(By.css('div')); 154 | 155 | expect(el).not.toBeNull(); 156 | }); 157 | 158 | it('should make el invisible on single screen mode', () => { 159 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(false); 160 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 161 | ScreenSpanning.None 162 | ); 163 | fixture.detectChanges(); 164 | el = fixture.debugElement.query(By.css('div')); 165 | 166 | expect(el).toBeNull(); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /projects/lib/src/core/if-span.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | EmbeddedViewRef, 4 | Input, 5 | OnDestroy, 6 | TemplateRef, 7 | ViewContainerRef, 8 | } from '@angular/core'; 9 | import { Subscription } from 'rxjs'; 10 | import { ScreenContext } from './screen-context'; 11 | import { ScreenSpanning } from './screen-spanning'; 12 | 13 | /** Spanning mode conditions for use with {@link IfSpanDirective}. */ 14 | export type SpanCondition = 15 | | 'dual-horizontal' 16 | | 'dual-vertical' 17 | | 'none' 18 | | 'multi'; 19 | /** 20 | * Enumeration of spanning mode conditions values for use with 21 | * {@link IfSpanDirective}. 22 | */ 23 | export const SpanCondition = { 24 | /** Screen spanning mode is dual horizontal viewports. */ 25 | Vertical: 'dual-horizontal' as SpanCondition, 26 | /** Screen spanning mode is dual vertical viewports. */ 27 | Horizontal: 'dual-vertical' as SpanCondition, 28 | /** No screen spanning (single screen mode). */ 29 | None: 'none' as SpanCondition, 30 | /** Any screen spanning mode is active (multi screen mode). */ 31 | Multi: 'multi' as SpanCondition, 32 | }; 33 | 34 | /** 35 | * Shows template only if the current screen spanning mode matches 36 | * specified {@link SpanCondition}. 37 | * 38 | * Behaves like `ngIf`, except that it accepts a {@link SpanCondition} instead of 39 | * a boolean expression. 40 | * 41 | * @example 42 | *

This text will only be visible on multi screen devices.

43 | * @example 44 | *

This text will only be visible on single screen devices.

45 | * This text will only be visible on multi screen devices. 46 | */ 47 | @Directive({ 48 | selector: '[fdIfSpan]', 49 | }) 50 | export class IfSpanDirective implements OnDestroy { 51 | private screenContextSubscription: Subscription | null = null; 52 | private condition: SpanCondition | null = null; 53 | private thenTemplateRef: TemplateRef | null = null; 54 | private elseTemplateRef: TemplateRef | null = null; 55 | private thenViewRef: EmbeddedViewRef | null = null; 56 | private elseViewRef: EmbeddedViewRef | null = null; 57 | 58 | /** 59 | * The spanning mode condition that defines if the template should be shown. 60 | * 61 | * @param condition The spanning mode condition for showing the template. 62 | */ 63 | @Input() 64 | set fdIfSpan(condition: SpanCondition) { 65 | if (condition !== this.condition) { 66 | this.condition = condition; 67 | this.updateView(); 68 | } 69 | } 70 | 71 | /** A template to show if the span condition evaluates to true. */ 72 | @Input() 73 | set fdIfSpanThen(templateRef: TemplateRef | null) { 74 | this.thenTemplateRef = templateRef; 75 | this.thenViewRef = null; 76 | this.updateView(); 77 | } 78 | 79 | /** A template to show if the span condition evaluates to false. */ 80 | @Input() 81 | set fdIfSpanElse(templateRef: TemplateRef | null) { 82 | this.elseTemplateRef = templateRef; 83 | this.thenViewRef = null; 84 | this.updateView(); 85 | } 86 | 87 | constructor( 88 | private screenContext: ScreenContext, 89 | private viewContainer: ViewContainerRef, 90 | templateRef: TemplateRef 91 | ) { 92 | this.thenTemplateRef = templateRef; 93 | this.screenContextSubscription = this.screenContext 94 | .asObservable() 95 | .subscribe(() => this.updateView()); 96 | } 97 | 98 | /** ignore */ 99 | ngOnDestroy() { 100 | if (this.screenContextSubscription !== null) { 101 | this.screenContextSubscription.unsubscribe(); 102 | } 103 | } 104 | 105 | private matchCondition(): boolean { 106 | switch (this.condition) { 107 | case SpanCondition.Multi: 108 | return this.screenContext.isMultiScreen; 109 | case SpanCondition.Horizontal: 110 | return this.screenContext.screenSpanning === ScreenSpanning.DualVertical; 111 | case SpanCondition.Vertical: 112 | return this.screenContext.screenSpanning === ScreenSpanning.DualHorizontal; 113 | default: 114 | return this.screenContext.screenSpanning === ScreenSpanning.None; 115 | } 116 | } 117 | 118 | private updateView() { 119 | const match = this.matchCondition(); 120 | 121 | if (match) { 122 | if (!this.thenViewRef) { 123 | this.viewContainer.clear(); 124 | this.elseViewRef = null; 125 | if (this.thenTemplateRef) { 126 | this.thenViewRef = this.viewContainer.createEmbeddedView( 127 | this.thenTemplateRef 128 | ); 129 | } 130 | } 131 | } else { 132 | if (!this.elseViewRef) { 133 | this.viewContainer.clear(); 134 | this.thenViewRef = null; 135 | if (this.elseTemplateRef) { 136 | this.elseViewRef = this.viewContainer.createEmbeddedView( 137 | this.elseTemplateRef 138 | ); 139 | } 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /projects/lib/src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './screen-context'; 2 | export * from './screen-spanning'; 3 | export * from './media-queries'; 4 | export * from './split-layout.directive'; 5 | export * from './window.directive'; 6 | export * from './if-span.directive'; 7 | export * from './foldable.module'; 8 | -------------------------------------------------------------------------------- /projects/lib/src/core/media-queries.ts: -------------------------------------------------------------------------------- 1 | /** Media query used to detect dual vertical viewports screen mode. */ 2 | export const dualVerticalViewport = '(vertical-viewport-segments: 2)'; 3 | /** Media query used to detect dual horizontal viewports screen mode. */ 4 | export const dualHorizontalViewport = '(horizontal-viewport-segments: 2)'; 5 | -------------------------------------------------------------------------------- /projects/lib/src/core/screen-context.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnDestroy } from '@angular/core'; 2 | import { fromEvent, merge, Observable, ReplaySubject } from 'rxjs'; 3 | import { map, filter, shareReplay, startWith, takeUntil } from 'rxjs/operators'; 4 | import { dualVerticalViewport, dualHorizontalViewport } from './media-queries'; 5 | import { ScreenSpanning } from './screen-spanning'; 6 | 7 | declare global { 8 | interface Window { 9 | getWindowSegments: () => DOMRect[]; 10 | } 11 | } 12 | 13 | /** 14 | * Holds information about the device screen context. 15 | */ 16 | export interface ScreenContextData { 17 | /** The list of available window segments. */ 18 | readonly windowSegments: DOMRect[]; 19 | /** The current screen spanning mode. */ 20 | readonly screenSpanning: ScreenSpanning; 21 | /** True is current device have multiple screens available. */ 22 | readonly isMultiScreen: boolean; 23 | } 24 | 25 | /** 26 | * This service allows to query and receive updates about current device's 27 | * screen context. 28 | * 29 | * See {@link ScreenContextData} 30 | */ 31 | @Injectable({ 32 | providedIn: 'root', 33 | }) 34 | export class ScreenContext implements ScreenContextData, OnDestroy { 35 | private currentContext: ScreenContextData; 36 | private screenContext$: Observable; 37 | private destroyed$: ReplaySubject = new ReplaySubject(1); 38 | 39 | constructor() { 40 | this.currentContext = this.getScreenContext(); 41 | this.screenContext$ = merge( 42 | fromEvent(matchMedia(dualVerticalViewport), 'change'), 43 | fromEvent(matchMedia(dualHorizontalViewport), 'change') 44 | ).pipe( 45 | filter( 46 | () => this.getScreenSpanning() !== this.currentContext.screenSpanning 47 | ), 48 | startWith(1), 49 | map(() => { 50 | this.currentContext = this.getScreenContext(); 51 | return this.currentContext; 52 | }), 53 | shareReplay(1), 54 | takeUntil(this.destroyed$) 55 | ); 56 | this.screenContext$.subscribe(); 57 | } 58 | 59 | /** @ignored */ 60 | ngOnDestroy() { 61 | this.destroyed$.next(); 62 | this.destroyed$.complete(); 63 | } 64 | 65 | /** 66 | * The list of available window segments. 67 | */ 68 | get windowSegments(): DOMRect[] { 69 | return this.currentContext.windowSegments; 70 | } 71 | 72 | /** 73 | * The current screen spanning mode. 74 | */ 75 | get screenSpanning(): ScreenSpanning { 76 | return this.currentContext.screenSpanning; 77 | } 78 | 79 | /** 80 | * True is current device have multiple screens available. 81 | */ 82 | get isMultiScreen(): boolean { 83 | return this.currentContext.isMultiScreen; 84 | } 85 | 86 | /** 87 | * Gets an observable emitting when the screen context changes. 88 | */ 89 | asObservable(): Observable { 90 | return this.screenContext$; 91 | } 92 | 93 | /** 94 | * Gets the current screen context. 95 | */ 96 | asObject(): ScreenContextData { 97 | return this.currentContext; 98 | } 99 | 100 | private getScreenContext(): ScreenContextData { 101 | const windowSegments = this.getWindowSegments(); 102 | const screenSpanning = this.getScreenSpanning(); 103 | return { 104 | windowSegments, 105 | screenSpanning, 106 | isMultiScreen: screenSpanning !== ScreenSpanning.None, 107 | }; 108 | } 109 | 110 | private getScreenSpanning(): ScreenSpanning { 111 | if (matchMedia(dualVerticalViewport).matches) { 112 | return ScreenSpanning.DualVertical; 113 | } else if (matchMedia(dualHorizontalViewport).matches) { 114 | return ScreenSpanning.DualHorizontal; 115 | } 116 | return ScreenSpanning.None; 117 | } 118 | 119 | private getWindowSegments(): DOMRect[] { 120 | if ('getWindowSegments' in window) { 121 | console.warn('getWindowSegments() is not supported anymore, please update your browser to use the new visualViewport API'); 122 | } 123 | if ('visualViewport' in window) { 124 | return (window.visualViewport as any).segments; 125 | } 126 | return [ 127 | new DOMRect( 128 | window.pageXOffset, 129 | window.pageYOffset, 130 | window.innerWidth, 131 | window.innerHeight 132 | ), 133 | ]; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /projects/lib/src/core/screen-spanning.ts: -------------------------------------------------------------------------------- 1 | /** Represents the screen spanning mode. */ 2 | export type ScreenSpanning = 'dual-horizontal' | 'dual-vertical' | 'none'; 3 | /** Enumeration of screen spanning mode values. */ 4 | export const ScreenSpanning = { 5 | /** Screen spanning mode is dual horizontal viewports. */ 6 | DualHorizontal: 'dual-horizontal' as ScreenSpanning, 7 | /** Screen spanning mode is dual vertical viewports. */ 8 | DualVertical: 'dual-vertical' as ScreenSpanning, 9 | /** No screen spanning (single screen mode). */ 10 | None: 'none' as ScreenSpanning, 11 | }; 12 | -------------------------------------------------------------------------------- /projects/lib/src/core/split-layout.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component, DebugElement } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { Subject } from 'rxjs'; 5 | import { ScreenContext, ScreenContextData } from './screen-context'; 6 | import { ScreenSpanning } from './screen-spanning'; 7 | import { SplitLayoutDirective } from './split-layout.directive'; 8 | 9 | @Component({ 10 | template: `
`, 11 | }) 12 | class TestComponent {} 13 | 14 | const getter = (obj: any, prop: string): jasmine.Spy => 15 | Object.getOwnPropertyDescriptor(obj, prop)?.get as jasmine.Spy; 16 | 17 | describe('SplitLayoutDirective', () => { 18 | let component: TestComponent; 19 | let fixture: ComponentFixture; 20 | let screenContextSpy: jasmine.SpyObj; 21 | let fakeObservable$: Subject; 22 | let el: DebugElement; 23 | 24 | beforeEach(() => { 25 | fakeObservable$ = new Subject(); 26 | screenContextSpy = jasmine.createSpyObj( 27 | 'ScreenContext', 28 | ['asObservable', 'asObject'], 29 | ['isMultiScreen', 'screenSpanning', 'windowSegments'] 30 | ); 31 | screenContextSpy.asObservable.and.returnValue(fakeObservable$); 32 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(false); 33 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 34 | ScreenSpanning.None 35 | ); 36 | 37 | fixture = TestBed.configureTestingModule({ 38 | declarations: [SplitLayoutDirective, TestComponent], 39 | providers: [{ provide: ScreenContext, useValue: screenContextSpy }], 40 | }).createComponent(TestComponent); 41 | 42 | fakeObservable$.next({} as ScreenContextData); 43 | component = fixture.componentInstance; 44 | }); 45 | 46 | it('should not add any css in single screen mode', () => { 47 | fixture.detectChanges(); 48 | el = fixture.debugElement.query(By.css('div')); 49 | 50 | expect(el.nativeElement.getAttribute('style')).toBeNull(); 51 | }); 52 | 53 | it('should add styling in multi screen mode', () => { 54 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(true); 55 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 56 | ScreenSpanning.DualVertical 57 | ); 58 | fakeObservable$.next({} as ScreenContextData); 59 | fixture.detectChanges(); 60 | el = fixture.debugElement.query(By.css('[fdSplitLayout]')); 61 | 62 | expect(el.nativeElement.getAttribute('style')).not.toBeNull(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /projects/lib/src/core/split-layout.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | ElementRef, 4 | HostBinding, 5 | Input, 6 | OnDestroy, 7 | } from '@angular/core'; 8 | import { SafeStyle } from '@angular/platform-browser'; 9 | import { Subscription } from 'rxjs'; 10 | import { ScreenContext } from './screen-context'; 11 | import { ScreenSpanning } from './screen-spanning'; 12 | 13 | /** 14 | * Defines how the split layout container should be rendered when multi screen 15 | * is detected. 16 | * See {@link SplitLayoutDirective} 17 | */ 18 | export type SplitLayoutMode = 'flex' | 'grid' | 'absolute'; 19 | /** 20 | * Enumeration of split layout modes values for use with 21 | * {@link SplitLayoutDirective}. 22 | */ 23 | export const SplitLayoutMode = { 24 | Flex: 'flex' as SplitLayoutMode, 25 | Grid: 'grid' as SplitLayoutMode, 26 | Absolute: 'absolute' as SplitLayoutMode, 27 | 28 | /** 29 | * Checks if the given string value is a valid {@link SplitLayoutMode}. 30 | * 31 | * @param value The value to check. 32 | * @return true if the value is a valid {@link SplitLayoutMode}. 33 | */ 34 | isValid: (value: string): boolean => { 35 | switch (value) { 36 | case SplitLayoutMode.Flex: 37 | case SplitLayoutMode.Grid: 38 | case SplitLayoutMode.Absolute: 39 | return true; 40 | } 41 | return false; 42 | }, 43 | }; 44 | 45 | /** 46 | * Defines how the split layout container should order the window segments 47 | * when in horizontal spanning mode vs vertical spanning mode. 48 | * See {@link SplitLayoutDirective} 49 | */ 50 | export type WindowOrder = 'normal' | 'reverse'; 51 | /** 52 | * Enumeration of window order values for use with 53 | * {@link SplitLayoutDirective}. 54 | */ 55 | export const WindowOrder = { 56 | Normal: 'normal' as WindowOrder, 57 | Reverse: 'reverse' as WindowOrder, 58 | 59 | /** 60 | * Checks if the given string value is a valid {@link WindowOrder}. 61 | * 62 | * @param value The value to check. 63 | * @return true if the value is a valid {@link WindowOrder}. 64 | */ 65 | isValid: (value: string): boolean => { 66 | switch (value) { 67 | case WindowOrder.Normal: 68 | case WindowOrder.Reverse: 69 | return true; 70 | } 71 | return false; 72 | }, 73 | }; 74 | 75 | /** 76 | * Defines the text reading direction for the host element. 77 | */ 78 | export type ReadingDirection = 'ltr' | 'rtl'; 79 | /** 80 | * Enumeration of the text reading direction values. 81 | */ 82 | export const ReadingDirection = { 83 | LeftToRight: 'ltr' as ReadingDirection, 84 | RightToLeft: 'rtl' as ReadingDirection, 85 | }; 86 | 87 | /** 88 | * Look 'ma, CSS-in-JS with Angular! ಠ_ಠ 89 | * 90 | * @ignore 91 | */ 92 | const layoutStyles = { 93 | [SplitLayoutMode.Flex]: { 94 | common: { 95 | display: 'flex', 96 | justifyContent: 'space-between', 97 | height: 'env(viewport-segment-bottom 0 1)', 98 | }, 99 | [ScreenSpanning.DualHorizontal]: { 100 | flexDirection: 'row', 101 | }, 102 | [ScreenSpanning.DualVertical]: { 103 | flexDirection: 'column', 104 | }, 105 | [WindowOrder.Reverse]: { 106 | flexDirection: 'column-reverse', 107 | }, 108 | }, 109 | [SplitLayoutMode.Grid]: { 110 | common: { 111 | display: 'grid', 112 | height: 'env(viewport-segment-bottom 0 1)', 113 | }, 114 | [ScreenSpanning.DualHorizontal]: { 115 | gridTemplateColumns: '1fr 1fr', 116 | gridTemplateAreas: '"segment0 segment1"', 117 | gridAutoFlow: 'row', 118 | columnGap: 'calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0))', 119 | }, 120 | [ScreenSpanning.DualVertical]: { 121 | gridTemplateRows: '1fr 1fr', 122 | gridTemplateAreas: '"segment0" "segment1"', 123 | gridAutoFlow: 'row', 124 | rowGap: 'calc(env(viewport-segment-top 0 1) - env(viewport-segment-bottom 0 0))', 125 | }, 126 | [WindowOrder.Reverse]: { 127 | gridTemplateRows: '1fr 1fr', 128 | gridTemplateAreas: '"segment0" "segment1"', 129 | gridAutoFlow: 'row', 130 | rowGap: 'calc(env(viewport-segment-top 0 1) - env(viewport-segment-bottom 0 0))', 131 | }, 132 | }, 133 | [SplitLayoutMode.Absolute]: { 134 | common: { 135 | position: 'relative', 136 | height: 'env(viewport-segment-bottom 0 1)', 137 | }, 138 | [ScreenSpanning.DualHorizontal]: {}, 139 | [ScreenSpanning.DualVertical]: {}, 140 | [WindowOrder.Reverse]: {}, 141 | }, 142 | }; 143 | 144 | /** 145 | * Defines a parent layout container for creating a split layout on multi 146 | * screen devices. 147 | * 148 | * When used on a single screen device, no layout change (CSS) is added. 149 | * You can choose between different {@link SplitLayoutMode} to suit your 150 | * design. 151 | * 152 | * This directive should be used along with {@link WindowDirective}. 153 | * 154 | * @example 155 | *
156 | *
Will be displayed on first screen
157 | *
Will be displayed on second screen (if available)
158 | *
159 | * 160 | * In addition, you can also choose keep the same window segments order or 161 | * reverse it when the spanning mode change from vertical to horizontal using 162 | * a second optional parameter on the directive: 163 | * 164 | * @example 165 | *
166 | *
167 | * Will be displayed on first screen in vertical spanning mode 168 | * and on the second screen in horizontal spanning mode. 169 | *
170 | *
171 | * Will be displayed on second screen in vertical spanning mode 172 | * and on the first screen in horizontal spanning mode. 173 | *
174 | *
175 | */ 176 | @Directive({ 177 | selector: '[fdSplitLayout]', 178 | }) 179 | export class SplitLayoutDirective implements OnDestroy { 180 | private mode: SplitLayoutMode = SplitLayoutMode.Flex; 181 | private order: WindowOrder = WindowOrder.Normal; 182 | private layoutStyle: SafeStyle = {}; 183 | private screenContextSubscription: Subscription | null = null; 184 | private direction: ReadingDirection = 'ltr'; 185 | 186 | /** 187 | * Sets the current split layout options to use when multi screen is 188 | * detected. 189 | * 190 | * @param options The split layout options to use. 191 | * Format: `[mode] [order]` 192 | * - The {@link SplitLayoutMode} to use (default is {@link SplitLayoutMode.Flex}). 193 | * - The {@link WindowOrder} to use (default is {@link WindowOrder.Normal}). 194 | */ 195 | @Input() 196 | set fdSplitLayout(options: string | undefined) { 197 | this.parseOptions(options || ''); 198 | this.updateStyle(); 199 | } 200 | 201 | /** @ignore */ 202 | @HostBinding('style') 203 | get style(): SafeStyle { 204 | return this.layoutStyle; 205 | } 206 | 207 | constructor( 208 | private element: ElementRef, 209 | private screenContext: ScreenContext 210 | ) { 211 | this.updateStyle(); 212 | this.screenContextSubscription = this.screenContext 213 | .asObservable() 214 | .subscribe(() => this.updateStyle()); 215 | } 216 | 217 | /** 218 | * The current split layout mode to use when multi screen is detected. 219 | * 220 | * @return The current split layout mode. 221 | */ 222 | get layoutMode(): SplitLayoutMode { 223 | return this.mode; 224 | } 225 | 226 | /** 227 | * The window segments order to use when in horizontal spanning mode. 228 | * 229 | * @return The current window order. 230 | */ 231 | get windowOrder(): WindowOrder { 232 | return this.order; 233 | } 234 | 235 | /** 236 | * The text reading direction for the host element. 237 | * 238 | * @return The text reading direction. 239 | */ 240 | get readingDirection(): ReadingDirection { 241 | return this.direction; 242 | } 243 | 244 | /** @ignore */ 245 | ngOnDestroy() { 246 | if (this.screenContextSubscription !== null) { 247 | this.screenContextSubscription.unsubscribe(); 248 | } 249 | } 250 | 251 | private parseOptions(options: string) { 252 | let [mode, order] = options.trim().split(' '); 253 | mode = SplitLayoutMode.isValid(mode) ? mode : SplitLayoutMode.Flex; 254 | order = WindowOrder.isValid(order) ? order : WindowOrder.Normal; 255 | this.mode = mode as SplitLayoutMode; 256 | this.order = order as WindowOrder; 257 | } 258 | 259 | private updateStyle() { 260 | const isMultiScreen = this.screenContext.isMultiScreen; 261 | const spanning = this.screenContext.screenSpanning; 262 | const reverse = 263 | spanning === ScreenSpanning.DualVertical && 264 | this.order === WindowOrder.Reverse; 265 | 266 | this.direction = 267 | (getComputedStyle(this.element.nativeElement) 268 | ?.direction as ReadingDirection) || ReadingDirection.LeftToRight; 269 | 270 | if (isMultiScreen && spanning !== ScreenSpanning.None) { 271 | this.layoutStyle = { 272 | ...layoutStyles[this.mode].common, 273 | ...layoutStyles[this.mode][reverse ? WindowOrder.Reverse : spanning], 274 | }; 275 | } else { 276 | this.layoutStyle = {}; 277 | } 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /projects/lib/src/core/window.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { WindowDirective } from './window.directive'; 2 | import { Component, DebugElement } from '@angular/core'; 3 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 4 | import { By } from '@angular/platform-browser'; 5 | import { Subject } from 'rxjs'; 6 | import { ScreenContext, ScreenContextData } from './screen-context'; 7 | import { ScreenSpanning } from './screen-spanning'; 8 | import { SplitLayoutDirective } from './split-layout.directive'; 9 | 10 | @Component({ 11 | template: ` 12 |
13 |
14 |
`, 15 | }) 16 | class TestComponent {} 17 | 18 | const getter = (obj: any, prop: string): jasmine.Spy => 19 | Object.getOwnPropertyDescriptor(obj, prop)?.get as jasmine.Spy; 20 | 21 | describe('WindowDirective', () => { 22 | let component: TestComponent; 23 | let fixture: ComponentFixture; 24 | let screenContextSpy: jasmine.SpyObj; 25 | let fakeObservable$: Subject; 26 | let el: DebugElement; 27 | 28 | beforeEach(() => { 29 | fakeObservable$ = new Subject(); 30 | screenContextSpy = jasmine.createSpyObj( 31 | 'ScreenContext', 32 | ['asObservable', 'asObject'], 33 | ['isMultiScreen', 'screenSpanning', 'windowSegments'] 34 | ); 35 | screenContextSpy.asObservable.and.returnValue(fakeObservable$); 36 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(false); 37 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 38 | ScreenSpanning.None 39 | ); 40 | 41 | fixture = TestBed.configureTestingModule({ 42 | declarations: [WindowDirective, SplitLayoutDirective, TestComponent], 43 | providers: [{ provide: ScreenContext, useValue: screenContextSpy }], 44 | }).createComponent(TestComponent); 45 | 46 | fakeObservable$.next({} as ScreenContextData); 47 | component = fixture.componentInstance; 48 | }); 49 | 50 | it('should not add any css in single screen mode', () => { 51 | fixture.detectChanges(); 52 | el = fixture.debugElement.query(By.css('section')); 53 | 54 | expect(el.nativeElement.getAttribute('style')).toBeNull(); 55 | }); 56 | 57 | it('should add styling in multi screen mode', () => { 58 | getter(screenContextSpy, 'isMultiScreen').and.returnValue(true); 59 | getter(screenContextSpy, 'screenSpanning').and.returnValue( 60 | ScreenSpanning.DualVertical 61 | ); 62 | fakeObservable$.next({} as ScreenContextData); 63 | fixture.detectChanges(); 64 | el = fixture.debugElement.query(By.css('[fdWindow]')); 65 | 66 | expect(el.nativeElement.getAttribute('style')).not.toBeNull(); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /projects/lib/src/core/window.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, Host, HostBinding, Input, OnDestroy } from '@angular/core'; 2 | import { SafeStyle } from '@angular/platform-browser'; 3 | import { Subscription } from 'rxjs'; 4 | import { ScreenContext } from './screen-context'; 5 | import { ScreenSpanning } from './screen-spanning'; 6 | import { 7 | ReadingDirection, 8 | SplitLayoutDirective, 9 | SplitLayoutMode, 10 | WindowOrder, 11 | } from './split-layout.directive'; 12 | 13 | /** 14 | * Look 'ma, CSS-in-JS with Angular! ಠ_ಠ 15 | * 16 | * @ignore 17 | */ 18 | const layoutStyles = { 19 | [SplitLayoutMode.Flex]: { 20 | [ScreenSpanning.DualHorizontal]: [ 21 | { flex: '0 0 env(viewport-segment-width 0 0)' }, 22 | { flex: '0 0 env(viewport-segment-width 1 0)' }, 23 | ], 24 | [ScreenSpanning.DualVertical]: [ 25 | { flex: '0 0 env(viewport-segment-height 0 0)' }, 26 | { flex: '0 0 env(viewport-segment-height 0 1)' }, 27 | ], 28 | }, 29 | [SplitLayoutMode.Grid]: { 30 | [ScreenSpanning.DualHorizontal]: [ 31 | { gridArea: 'segment0' }, 32 | { gridArea: 'segment1' }, 33 | ], 34 | [ScreenSpanning.DualVertical]: [ 35 | { 36 | gridArea: 'segment0', 37 | height: 'env(viewport-segment-height 0 0)' 38 | }, 39 | { 40 | gridArea: 'segment1', 41 | height: 'env(viewport-segment-height 0 1)' 42 | }, 43 | ], 44 | }, 45 | [SplitLayoutMode.Absolute]: { 46 | [ScreenSpanning.DualHorizontal]: [ 47 | { 48 | position: 'absolute', 49 | left: 0, 50 | width: 'env(viewport-segment-right 0 0)', 51 | }, 52 | { 53 | position: 'absolute', 54 | left: 'env(viewport-segment-left 1 0)', 55 | right: 0, 56 | }, 57 | ], 58 | [ScreenSpanning.DualVertical]: [ 59 | { 60 | position: 'absolute', 61 | top: 0, 62 | width: '100%', 63 | maxHeight: 'env(viewport-segment-height 0 0)', 64 | }, 65 | { 66 | position: 'absolute', 67 | top: 'env(viewport-segment-top 0 1)', 68 | width: '100%', 69 | maxHeight: 'env(viewport-segment-height 0 1)', 70 | }, 71 | ], 72 | }, 73 | }; 74 | 75 | /** 76 | * This directive is used to set specify on which window segment the container 77 | * should be placed on multi screen devices. 78 | * 79 | * When used on a single screen device, no layout change (CSS) is added. 80 | * Only devices with up to two screen are currently supported, meaning that the 81 | * window segment value must be either 0 or 1. 82 | * 83 | * This directive can only be used within a {@link SplitLayoutDirective}. 84 | * If {@link SplitLayoutMode} is set to `absolute`, you can assign multiple 85 | * container element to the same window segment. 86 | * 87 | * Note that if you have set the read direction to Right-To-Left mode (`rtl`) 88 | * in CSS, the first segment will be the rightmost one. 89 | * 90 | * If the {@link WindowOrder} option is set to {@link WindowOrder.Reverse}, 91 | * the window segments order will be reversed in horizontal spanning mode. 92 | * 93 | * @example 94 | *
95 | *
Will be displayed on first screen
96 | *
Will be displayed on second screen (if available)
97 | *
98 | */ 99 | @Directive({ 100 | selector: '[fdWindow]', 101 | }) 102 | export class WindowDirective implements OnDestroy { 103 | private segment = -1; 104 | private layoutStyle: SafeStyle = {}; 105 | private screenContextSubscription: Subscription | null = null; 106 | 107 | /** @ignore */ 108 | @HostBinding('style') 109 | get style(): SafeStyle { 110 | return this.layoutStyle; 111 | } 112 | 113 | /** 114 | * Sets the target window segment to display this container on when multi 115 | * screen is detected. 116 | * 117 | * @param segment The target window segment, must be 0 or 1. 118 | */ 119 | @Input() 120 | set fdWindow(segment: number | string) { 121 | segment = typeof segment === 'string' ? parseInt(segment, 10) : segment; 122 | if (segment !== this.segment) { 123 | this.segment = segment; 124 | this.updateStyle(); 125 | } 126 | } 127 | 128 | constructor( 129 | private screenContext: ScreenContext, 130 | @Host() private splitLayout: SplitLayoutDirective 131 | ) { 132 | this.screenContextSubscription = this.screenContext 133 | .asObservable() 134 | .subscribe(() => this.updateStyle()); 135 | } 136 | 137 | /** @ignore */ 138 | ngOnDestroy() { 139 | if (this.screenContextSubscription !== null) { 140 | this.screenContextSubscription.unsubscribe(); 141 | } 142 | } 143 | 144 | private updateStyle() { 145 | if (this.segment === -1) { 146 | return; 147 | } 148 | 149 | const isMultiScreen = this.screenContext.isMultiScreen; 150 | const spanning = this.screenContext.screenSpanning; 151 | 152 | if (isMultiScreen && spanning !== ScreenSpanning.None) { 153 | if (this.segment < 0 || this.segment > 1) { 154 | throw new Error('Segment index must be 0 or 1'); 155 | } 156 | 157 | const mode = this.splitLayout.layoutMode; 158 | const order = this.splitLayout.windowOrder; 159 | const direction = this.splitLayout.readingDirection; 160 | // Swap segments for vertical span and RTL mode or 161 | // horizontal span and reverse window order 162 | const swap = 163 | (spanning === ScreenSpanning.DualHorizontal && 164 | mode !== SplitLayoutMode.Grid && 165 | direction === ReadingDirection.RightToLeft) || 166 | (spanning === ScreenSpanning.DualVertical && 167 | order === WindowOrder.Reverse); 168 | 169 | const segment = swap ? 1 - this.segment : this.segment; 170 | this.layoutStyle = layoutStyles[mode][spanning][segment]; 171 | } else { 172 | this.layoutStyle = {}; 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /projects/lib/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ngx-foldable 3 | */ 4 | 5 | export * from './core/screen-context'; 6 | export * from './core/screen-spanning'; 7 | export * from './core/media-queries'; 8 | export * from './core/split-layout.directive'; 9 | export * from './core/window.directive'; 10 | export * from './core/if-span.directive'; 11 | export * from './core/foldable.module'; 12 | -------------------------------------------------------------------------------- /projects/lib/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "inlineSources": true, 9 | "types": [], 10 | }, 11 | "exclude": [ 12 | "**/*.spec.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /projects/lib/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/lib/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "**/*.spec.ts", 12 | "**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitReturns": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "sourceMap": true, 11 | "declaration": false, 12 | "downlevelIteration": true, 13 | "experimentalDecorators": true, 14 | "moduleResolution": "node", 15 | "importHelpers": true, 16 | "target": "ES2022", 17 | "module": "ES2022", 18 | "useDefineForClassFields": false, 19 | "lib": [ 20 | "ES2022", 21 | "dom" 22 | ], 23 | "paths": { 24 | "ngx-foldable": [ 25 | "projects/lib/src/core/" 26 | ] 27 | } 28 | }, 29 | "angularCompilerOptions": { 30 | "enableI18nLegacyMessageIdFormat": false, 31 | "strictInjectionParameters": true, 32 | "strictInputAccessModifiers": true, 33 | "strictTemplates": true 34 | } 35 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "align": { 8 | "options": [ 9 | "parameters", 10 | "statements" 11 | ] 12 | }, 13 | "array-type": false, 14 | "arrow-return-shorthand": true, 15 | "curly": true, 16 | "deprecation": { 17 | "severity": "warning" 18 | }, 19 | "eofline": true, 20 | "import-blacklist": [ 21 | true, 22 | "rxjs/Rx" 23 | ], 24 | "import-spacing": true, 25 | "indent": { 26 | "options": [ 27 | "spaces" 28 | ] 29 | }, 30 | "max-classes-per-file": false, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-console": [ 47 | true, 48 | "debug", 49 | "info", 50 | "time", 51 | "timeEnd", 52 | "trace" 53 | ], 54 | "no-empty": false, 55 | "no-inferrable-types": [ 56 | true, 57 | "ignore-params" 58 | ], 59 | "no-non-null-assertion": true, 60 | "no-redundant-jsdoc": true, 61 | "no-switch-case-fall-through": true, 62 | "no-var-requires": false, 63 | "object-literal-key-quotes": [ 64 | true, 65 | "as-needed" 66 | ], 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "semicolon": { 72 | "options": [ 73 | "always" 74 | ] 75 | }, 76 | "space-before-function-paren": { 77 | "options": { 78 | "anonymous": "never", 79 | "asyncArrow": "always", 80 | "constructor": "never", 81 | "method": "never", 82 | "named": "never" 83 | } 84 | }, 85 | "typedef": [ 86 | true, 87 | "call-signature" 88 | ], 89 | "typedef-whitespace": { 90 | "options": [ 91 | { 92 | "call-signature": "nospace", 93 | "index-signature": "nospace", 94 | "parameter": "nospace", 95 | "property-declaration": "nospace", 96 | "variable-declaration": "nospace" 97 | }, 98 | { 99 | "call-signature": "onespace", 100 | "index-signature": "onespace", 101 | "parameter": "onespace", 102 | "property-declaration": "onespace", 103 | "variable-declaration": "onespace" 104 | } 105 | ] 106 | }, 107 | "variable-name": { 108 | "options": [ 109 | "ban-keywords", 110 | "check-format", 111 | "allow-pascal-case" 112 | ] 113 | }, 114 | "whitespace": { 115 | "options": [ 116 | "check-branch", 117 | "check-decl", 118 | "check-operator", 119 | "check-separator", 120 | "check-type", 121 | "check-typecast" 122 | ] 123 | }, 124 | "component-class-suffix": true, 125 | "contextual-lifecycle": true, 126 | "directive-class-suffix": true, 127 | "no-conflicting-lifecycle": true, 128 | "no-host-metadata-property": true, 129 | "no-input-rename": true, 130 | "no-inputs-metadata-property": true, 131 | "no-output-native": true, 132 | "no-output-on-prefix": true, 133 | "no-output-rename": true, 134 | "no-outputs-metadata-property": true, 135 | "template-banana-in-box": true, 136 | "template-no-negated-async": true, 137 | "use-lifecycle-interface": true, 138 | "use-pipe-transform-interface": true 139 | } 140 | } 141 | --------------------------------------------------------------------------------