├── src ├── demo │ ├── assets │ │ └── .gitkeep │ ├── favicon.ico │ ├── app │ │ ├── anchor-reuse │ │ │ ├── anchor-reuse.component.scss │ │ │ └── anchor-reuse.component.ts │ │ ├── positioning │ │ │ ├── positioning.component.scss │ │ │ └── positioning.component.ts │ │ ├── scroll-strategies │ │ │ ├── scroll-strategies.component.scss │ │ │ └── scroll-strategies.component.ts │ │ ├── demo.component.scss │ │ ├── select-trigger │ │ │ ├── select-trigger.component.scss │ │ │ └── select-trigger.component.ts │ │ ├── transitions │ │ │ ├── transitions.component.scss │ │ │ └── transitions.component.ts │ │ ├── focus │ │ │ ├── focus.component.scss │ │ │ └── focus.component.ts │ │ ├── tooltip │ │ │ ├── tooltip.component.scss │ │ │ └── tooltip.component.ts │ │ ├── speed-dial │ │ │ ├── speed-dial.component.scss │ │ │ └── speed-dial.component.ts │ │ ├── action-api │ │ │ ├── action-api.component.scss │ │ │ └── action-api.component.ts │ │ ├── interactive-close │ │ │ ├── interactive-close.component.scss │ │ │ └── interactive-close.component.ts │ │ ├── demo.module.ts │ │ └── demo.component.ts │ ├── browserslist │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── tslint.json │ ├── tsconfig.spec.json │ ├── tsconfig.app.json │ ├── main.ts │ ├── test.ts │ ├── index.html │ ├── karma.conf.js │ ├── styles.scss │ └── polyfills.ts └── lib │ ├── tsconfig.lib.prod.json │ ├── ng-package.prod.json │ ├── ng-package.json │ ├── tslint.json │ ├── tsconfig.spec.json │ ├── package.json │ ├── popover │ ├── tokens.ts │ ├── popover.component.html │ ├── popover.animations.ts │ ├── types.ts │ ├── popover.module.ts │ ├── popover.component.scss │ ├── popover.errors.ts │ ├── popover-hover.directive.ts │ ├── popover.component.ts │ ├── popover-anchoring.service.ts │ └── popover.spec.ts │ ├── test.ts │ ├── public_api.ts │ ├── tsconfig.lib.json │ └── karma.conf.js ├── .prettierignore ├── tools ├── tsconfig.json ├── constants.ts ├── release.ts └── prepare-package.ts ├── .github └── ISSUE_TEMPLATE.md ├── .prettierrc ├── .travis.yml ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── CONTRIBUTING.md ├── .eslintrc.json ├── LICENSE ├── package.json ├── angular.json ├── README.md └── CHANGELOG.md /src/demo/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | LICENSE 3 | node_modules 4 | -------------------------------------------------------------------------------- /src/demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncstate-sat/popover/HEAD/src/demo/favicon.ico -------------------------------------------------------------------------------- /tools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "es2022" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/demo/app/anchor-reuse/anchor-reuse.component.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: black; 3 | color: white; 4 | padding: 8px; 5 | font-size: 16px; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "angularCompilerOptions": { 4 | "compilationMode": "partial" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/demo/browserslist: -------------------------------------------------------------------------------- 1 | last 2 Chrome versions 2 | last 1 Firefox version 3 | last 2 Edge major versions 4 | last 2 Safari major versions 5 | last 2 iOS major versions 6 | Firefox ESR 7 | -------------------------------------------------------------------------------- /src/demo/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | import * as pkg from '../../../package.json'; 2 | 3 | export const environment = { 4 | production: true, 5 | version: pkg.version 6 | }; 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/lib/ng-package.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/popover", 4 | "lib": { 5 | "entryFile": "./public_api.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/demo/app/positioning/positioning.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .config { 6 | margin-bottom: 16px; 7 | } 8 | 9 | .popover { 10 | background: lightgray; 11 | padding: 32px; 12 | } 13 | -------------------------------------------------------------------------------- /src/demo/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "demo", "camelCase"], 5 | "component-selector": [true, "element", "demo", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/popover", 4 | "deleteDestPath": false, 5 | "lib": { 6 | "entryFile": "./public_api.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "sat", "camelCase"], 5 | "component-selector": [true, "element", "sat", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "bracketSpacing": true, 8 | "htmlWhitespaceSensitivity": "strict", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /src/demo/app/scroll-strategies/scroll-strategies.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .anchor { 6 | margin: 48px; 7 | } 8 | 9 | .popover { 10 | padding: 48px; 11 | color: white; 12 | background: black; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["test.ts"], 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/demo/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["test.ts", "polyfills.ts"], 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "importHelpers": true, 5 | "outDir": "../../out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["main.ts", "polyfills.ts"], 9 | "include": ["src/demo/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ncstate/sat-popover", 3 | "version": "AUTOGENERATED", 4 | "description": "Popover component for Angular", 5 | "author": "Will Howell", 6 | "license": "MIT", 7 | "peerDependencies": { 8 | "AUTOGENERATED": "AUTOGENERATED" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/popover/tokens.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | 3 | // See http://cubic-bezier.com/#.25,.8,.25,1 for reference. 4 | // const DEFAULT_TRANSITION = '200ms cubic-bezier(0.25, 0.8, 0.25, 1)'; 5 | export const DEFAULT_TRANSITION = new InjectionToken('DefaultTransition'); 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: node_js 3 | node_js: 4 | - '22' 5 | - 'lts/*' 6 | 7 | before_install: 8 | - export CHROME_BIN=chromium-browser 9 | - export DISPLAY=:99.0 10 | - sh -e /etc/init.d/xvfb start 11 | 12 | before_script: 13 | - npm install 14 | 15 | script: npm run test:once 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://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 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/lib/popover/popover.component.html: -------------------------------------------------------------------------------- 1 | 2 |
9 | 10 |
11 |
12 | -------------------------------------------------------------------------------- /src/demo/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { DemoModule } from './app/demo.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(DemoModule) 13 | .catch((err) => console.log(err)); 14 | -------------------------------------------------------------------------------- /src/demo/app/demo.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | min-height: 100%; 4 | } 5 | 6 | .mat-toolbar { 7 | justify-content: space-between; 8 | } 9 | 10 | .version { 11 | padding-left: 8px; 12 | } 13 | 14 | .page-content { 15 | padding: 48px; 16 | 17 | & > * { 18 | // separate each example 19 | margin-bottom: 32px; 20 | } 21 | } 22 | 23 | .repo-link { 24 | color: white; 25 | text-decoration: none; 26 | margin: 0; 27 | } 28 | -------------------------------------------------------------------------------- /src/demo/app/select-trigger/select-trigger.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .fancy-caption { 6 | background: yellow; 7 | border: dashed 20px orange; 8 | border-radius: 50%; 9 | font-size: 40px; 10 | height: 64px; 11 | width: 64px; 12 | line-height: 64px; 13 | text-align: center; 14 | 15 | transform: rotate(360deg); 16 | transition: transform 600ms ease-out; 17 | 18 | &.opened { 19 | transform: rotate(0deg); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/demo/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 6 | 7 | // First, initialize the Angular testing environment. 8 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); 9 | -------------------------------------------------------------------------------- /src/lib/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'core-js/es7/reflect'; 4 | import 'zone.js'; 5 | import 'zone.js/testing'; 6 | import { getTestBed } from '@angular/core/testing'; 7 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 8 | 9 | // First, initialize the Angular testing environment. 10 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); 11 | -------------------------------------------------------------------------------- /src/demo/app/transitions/transitions.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .indicators { 6 | display: flex; 7 | margin-bottom: 32px; 8 | } 9 | 10 | .indicator { 11 | margin-right: 8px; 12 | padding: 8px; 13 | background: pink; 14 | 15 | &.active { 16 | background: red; 17 | } 18 | } 19 | 20 | .anchor { 21 | background: darkblue; 22 | cursor: pointer; 23 | height: 48px; 24 | width: 48px; 25 | } 26 | 27 | .popover { 28 | background: lightgreen; 29 | color: rgba(0, 0, 0, 0.87); 30 | padding: 24px; 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/public_api.ts: -------------------------------------------------------------------------------- 1 | export { SatPopoverModule } from './popover/popover.module'; 2 | export { SatPopoverAnchoringService } from './popover/popover-anchoring.service'; 3 | export { SatPopoverComponent, SatPopoverAnchorDirective } from './popover/popover.component'; 4 | export { SatPopoverHoverDirective } from './popover/popover-hover.directive'; 5 | export { DEFAULT_TRANSITION } from './popover/tokens'; 6 | export { 7 | SatPopoverHorizontalAlign, 8 | SatPopoverVerticalAlign, 9 | SatPopoverScrollStrategy, 10 | SatPopoverOpenOptions 11 | } from './popover/types'; 12 | -------------------------------------------------------------------------------- /src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SAT Popover 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/demo/app/focus/focus.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | :host { 4 | display: block; 5 | } 6 | 7 | .results { 8 | background: rgba(0, 0, 0, 0.06); 9 | display: inline-block; 10 | padding: 32px; 11 | position: relative; 12 | 13 | p { 14 | margin: 4px 0; 15 | } 16 | } 17 | 18 | .edit { 19 | position: absolute; 20 | right: 0; 21 | top: 0; 22 | margin: 4px; 23 | color: rgba(0, 0, 0, 0.54); 24 | } 25 | 26 | .form { 27 | @include mat.elevation(8); 28 | display: flex; 29 | flex-direction: column; 30 | padding: 24px; 31 | background: white; 32 | } 33 | -------------------------------------------------------------------------------- /tools/constants.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | // paths 4 | export const SOURCE_PACKAGE_PATH = join(__dirname, '..', 'package.json'); 5 | export const SOURCE_README_PATH = join(__dirname, '..', 'README.md'); 6 | export const DIST_PATH = join(__dirname, '..', 'dist', 'popover'); 7 | export const DIST_PACKAGE_PATH = join(DIST_PATH, 'package.json'); 8 | export const DIST_README_PATH = join(DIST_PATH, 'README.md'); 9 | 10 | // config 11 | export const PEER_DEPENDENCIES = ['@angular/common', '@angular/core', '@angular/cdk']; 12 | export const PACKAGE_PROPERTIES = ['keywords', 'repository', 'bugs', 'homepage']; 13 | -------------------------------------------------------------------------------- /.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 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | .angular 42 | -------------------------------------------------------------------------------- /src/lib/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declarationMap": true, 6 | "target": "es2022", 7 | "module": "es2022", 8 | "useDefineForClassFields": true, 9 | "declaration": true, 10 | "sourceMap": true, 11 | "inlineSources": true, 12 | "esModuleInterop": true, 13 | "experimentalDecorators": true, 14 | "importHelpers": true, 15 | "types": [], 16 | "lib": ["dom", "es2022"] 17 | }, 18 | "angularCompilerOptions": { 19 | "skipTemplateCodegen": true, 20 | "strictMetadataEmit": true, 21 | "fullTemplateTypeCheck": true, 22 | "strictInjectionParameters": true 23 | }, 24 | "exclude": ["test.ts", "**/*.spec.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "module": "es2022", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "stripInternal": true, 10 | "moduleResolution": "bundler", 11 | "esModuleInterop": true, 12 | "experimentalDecorators": true, 13 | "target": "es2022", 14 | "typeRoots": ["node_modules/@types"], 15 | "lib": ["es2022", "dom"], 16 | "paths": { 17 | "@ncstate/sat-popover": ["dist/popover"], 18 | "core-js/es7/reflect": ["node_modules/core-js/proposals/reflect-metadata", "node_modules/core-js/es/reflect"], 19 | "core-js/es6/*": ["node_modules/core-js/es"] 20 | }, 21 | "resolveJsonModule": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/demo/app/tooltip/tooltip.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .mat-card-content { 6 | display: flex; 7 | flex-direction: column; 8 | align-items: flex-start; 9 | } 10 | 11 | .anchor { 12 | cursor: default; 13 | display: inline-block; 14 | background: rgba(0, 0, 0, 0.1); 15 | padding: 8px; 16 | margin: 16px; 17 | } 18 | 19 | .tooltip-wrapper { 20 | padding: 8px; 21 | background: rgba(50, 50, 50, 0.9); 22 | color: white; 23 | border-radius: 2px; 24 | margin: 8px; 25 | font-size: 12px; 26 | } 27 | 28 | .seagreen { 29 | color: lightseagreen; 30 | } 31 | 32 | .hover-text { 33 | display: inline-block; 34 | padding: 4px 8px; 35 | background: rgba(0, 0, 0, 0.1); 36 | 37 | &:hover { 38 | background: rgb(152, 194, 223); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/demo/environments/environment.ts: -------------------------------------------------------------------------------- 1 | import * as pkg from '../../../package.json'; 2 | 3 | // This file can be replaced during build by using the `fileReplacements` array. 4 | // `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. 5 | // The list of file replacements can be found in `angular.json`. 6 | 7 | export const environment = { 8 | production: false, 9 | version: pkg.version 10 | }; 11 | 12 | /* 13 | * In development mode, to ignore zone related error stack frames such as 14 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 15 | * import the following file, but please comment it out in production mode 16 | * because it will have performance impact when throw error 17 | */ 18 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 19 | -------------------------------------------------------------------------------- /src/lib/popover/popover.animations.ts: -------------------------------------------------------------------------------- 1 | import { trigger, state, style, animate, transition, AnimationTriggerMetadata } from '@angular/animations'; 2 | 3 | export const transformPopover: AnimationTriggerMetadata = trigger('transformPopover', [ 4 | state('enter', style({ opacity: 1, transform: 'scale(1)' }), { params: { startAtScale: 0.3 } }), 5 | state('void, exit', style({ opacity: 0, transform: 'scale({{endAtScale}})' }), { params: { endAtScale: 0.5 } }), 6 | transition('* => enter', [ 7 | style({ opacity: 0, transform: 'scale({{endAtScale}})' }), 8 | animate('{{openTransition}}', style({ opacity: 1, transform: 'scale(1)' })) 9 | ]), 10 | transition('* => void, * => exit', [ 11 | animate('{{closeTransition}}', style({ opacity: 0, transform: 'scale({{endAtScale}})' })) 12 | ]) 13 | ]); 14 | -------------------------------------------------------------------------------- /src/demo/app/speed-dial/speed-dial.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | position: fixed; 4 | right: 32px; 5 | bottom: 32px; 6 | // Remove default margin given to each demo 7 | margin-bottom: 0 !important; 8 | } 9 | 10 | // Position the icon in the center so multiple 11 | // ones will overlap 12 | .mat-fab .mat-icon { 13 | position: absolute; 14 | top: 16px; 15 | left: 16px; 16 | } 17 | 18 | .dial { 19 | margin-bottom: 8px; 20 | 21 | // Tab direction moves away from anchor 22 | display: flex; 23 | flex-direction: column-reverse; 24 | 25 | .mat-mini-fab { 26 | margin: 8px 0; 27 | } 28 | } 29 | 30 | .tooltip { 31 | padding: 4px 8px; 32 | background: rgba(50, 50, 50, 0.9); 33 | color: white; 34 | border-radius: 2px; 35 | margin: 8px; 36 | font-size: 12px; 37 | } 38 | -------------------------------------------------------------------------------- /src/demo/app/action-api/action-api.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | $info-panel-bg: rgb(83, 83, 83); 4 | 5 | :host { 6 | display: block; 7 | } 8 | 9 | .avatar { 10 | @include mat.elevation(1); 11 | background: rgba(77, 204, 255, 0.8); 12 | color: white; 13 | display: inline-block; 14 | height: 48px; 15 | width: 48px; 16 | line-height: 48px; 17 | text-align: center; 18 | border-radius: 50%; 19 | font-size: 20px; 20 | } 21 | 22 | .info { 23 | @include mat.elevation(4); 24 | background: $info-panel-bg; 25 | color: white; 26 | border-radius: 4px; 27 | position: relative; 28 | margin-left: 6px; 29 | padding: 12px; 30 | } 31 | 32 | .caret { 33 | height: 8px; 34 | width: 8px; 35 | position: absolute; 36 | left: -4px; 37 | top: 50%; 38 | transform: translateY(-50%) rotate(45deg); 39 | background: $info-panel-bg; 40 | } 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Build the library 4 | 5 | ```bash 6 | npm run build 7 | ``` 8 | 9 | ## Run the demo server 10 | 11 | ```bash 12 | npm run demo 13 | ``` 14 | 15 | Open the server [using this link to the localhost](http://localhost:4200) 16 | 17 | ## Testing 18 | 19 | ```bash 20 | npm run test 21 | npm run test:once 22 | ``` 23 | 24 | ## Releases 25 | 26 | - Check out a branch and edit package version and add changelog entry 27 | - Run `npm install` again to update `package-lock.json` 28 | - Open PR and merge into `master` 29 | - Run `git checkout master && git pull origin master` 30 | - Make sure everything is 👌 31 | - Run this script via `npm run release` 32 | 33 | > Note: If you have 2FA configured for npm.js (and you should), run: `npm run release --otp=XXXXXX` 34 | 35 | - Build and publish the demo app `npm run build:demo && npm run gh-pages` 36 | - Update all the official StackBlitz demos 37 | - Edit release on Github 38 | -------------------------------------------------------------------------------- /src/demo/app/interactive-close/interactive-close.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | $error-text: #e63922; 4 | 5 | :host { 6 | display: block; 7 | } 8 | 9 | .options { 10 | @include mat.elevation(8); 11 | display: flex; 12 | flex-direction: column; 13 | padding: 24px; 14 | background: white; 15 | 16 | .error { 17 | color: $error-text; 18 | } 19 | 20 | .mat-button { 21 | margin-top: 8px; 22 | } 23 | } 24 | 25 | .shake { 26 | animation: shake 300ms ease-in-out; 27 | } 28 | 29 | @keyframes shake { 30 | 0% { 31 | transform: translateX(0); 32 | } 33 | 12.5% { 34 | transform: translateX(-6px) rotateY(-5deg); 35 | } 36 | 37.5% { 37 | transform: translateX(5px) rotateY(4deg); 38 | } 39 | 62.5% { 40 | transform: translateX(-3px) rotateY(-2deg); 41 | } 42 | 87.5% { 43 | transform: translateX(2px) rotateY(1deg); 44 | } 45 | 100% { 46 | transform: translateX(0); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/popover/types.ts: -------------------------------------------------------------------------------- 1 | export type SatPopoverScrollStrategy = 'noop' | 'block' | 'reposition' | 'close'; 2 | export const VALID_SCROLL: SatPopoverScrollStrategy[] = ['noop', 'block', 'reposition', 'close']; 3 | 4 | export type SatPopoverHorizontalAlign = 'before' | 'start' | 'center' | 'end' | 'after'; 5 | export const VALID_HORIZ_ALIGN: SatPopoverHorizontalAlign[] = ['before', 'start', 'center', 'end', 'after']; 6 | 7 | export type SatPopoverVerticalAlign = 'above' | 'start' | 'center' | 'end' | 'below'; 8 | export const VALID_VERT_ALIGN: SatPopoverVerticalAlign[] = ['above', 'start', 'center', 'end', 'below']; 9 | 10 | export interface SatPopoverOpenOptions { 11 | /** 12 | * Whether the popover should return focus to the previously focused element after 13 | * closing. Defaults to true. 14 | */ 15 | restoreFocus?: boolean; 16 | 17 | /** Whether the first focusable element should be focused on open. Defaults to true. */ 18 | autoFocus?: boolean; 19 | } 20 | -------------------------------------------------------------------------------- /src/demo/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /src/lib/popover/popover.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { OverlayModule } from '@angular/cdk/overlay'; 4 | import { A11yModule } from '@angular/cdk/a11y'; 5 | import { BidiModule } from '@angular/cdk/bidi'; 6 | 7 | import { SatPopoverComponent, SatPopoverAnchorDirective } from './popover.component'; 8 | import { SatPopoverHoverDirective } from './popover-hover.directive'; 9 | import { DEFAULT_TRANSITION } from './tokens'; 10 | 11 | @NgModule({ 12 | imports: [ 13 | CommonModule, 14 | OverlayModule, 15 | A11yModule, 16 | BidiModule, 17 | SatPopoverComponent, 18 | SatPopoverAnchorDirective, 19 | SatPopoverHoverDirective 20 | ], 21 | providers: [ 22 | // See http://cubic-bezier.com/#.25,.8,.25,1 for reference. 23 | { provide: DEFAULT_TRANSITION, useValue: '200ms cubic-bezier(0.25, 0.8, 0.25, 1)' } 24 | ], 25 | exports: [SatPopoverComponent, SatPopoverAnchorDirective, SatPopoverHoverDirective, BidiModule] 26 | }) 27 | export class SatPopoverModule {} 28 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:@angular-eslint/recommended", 15 | "plugin:@angular-eslint/template/process-inline-templates" 16 | ], 17 | "rules": { 18 | "@angular-eslint/directive-selector": [ 19 | "error", 20 | { 21 | "type": "attribute", 22 | "prefix": "", 23 | "style": "camelCase" 24 | } 25 | ], 26 | "@angular-eslint/component-selector": [ 27 | "error", 28 | { 29 | "type": "element", 30 | "prefix": "", 31 | "style": "kebab-case" 32 | } 33 | ] 34 | } 35 | }, 36 | { 37 | "files": [ 38 | "*.html" 39 | ], 40 | "extends": [ 41 | "plugin:@angular-eslint/template/recommended", 42 | "plugin:@angular-eslint/template/accessibility" 43 | ], 44 | "rules": {} 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 North Carolina State University Security Applications & Technologies 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. 22 | -------------------------------------------------------------------------------- /src/demo/styles.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | // Material theming 4 | $ncstate-primary: mat.m2-define-palette(mat.$m2-red-palette, 700); 5 | $ncstate-accent: mat.m2-define-palette(mat.$m2-blue-grey-palette); 6 | $ncstate-warn: mat.m2-define-palette(mat.$m2-deep-orange-palette); 7 | 8 | $ncstate-theme: mat.m2-define-light-theme( 9 | ( 10 | color: ( 11 | primary: $ncstate-primary, 12 | accent: $ncstate-accent, 13 | warn: $ncstate-warn 14 | ), 15 | typography: mat.m2-define-typography-config(), 16 | density: 0 17 | ) 18 | ); 19 | 20 | $gradient-opacity: 0.48; 21 | 22 | @include mat.core(); 23 | @include mat.all-component-themes($ncstate-theme); 24 | 25 | html, 26 | body { 27 | height: 100%; 28 | margin: 0; 29 | } 30 | 31 | .demo-background-rainbow { 32 | background: linear-gradient( 33 | to right, 34 | rgba(255, 165, 0, $gradient-opacity), 35 | rgba(255, 255, 0, $gradient-opacity), 36 | rgba(0, 128, 0, $gradient-opacity), 37 | rgba(0, 255, 255, $gradient-opacity), 38 | rgba(0, 0, 255, $gradient-opacity), 39 | rgba(238, 130, 238, $gradient-opacity) 40 | ); 41 | } 42 | 43 | .demo-background-dark { 44 | background: rgba(0, 0, 0, 0.6); 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | var configuration = { 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../../coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | customLaunchers: { 31 | Chrome_travis_ci: { 32 | base: 'Chrome', 33 | flags: ['--no-sandbox'] 34 | } 35 | } 36 | }; 37 | 38 | // Run with --no-sandbox mode on Travis 39 | if (process.env.TRAVIS) { 40 | configuration.browsers = ['Chrome_travis_ci']; 41 | } 42 | 43 | config.set(configuration); 44 | }; 45 | -------------------------------------------------------------------------------- /src/lib/popover/popover.component.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/cdk' as cdk; 2 | 3 | // Include overlay styles 4 | @include cdk.overlay(); 5 | 6 | .sat-popover-container { 7 | &.sat-popover-before.sat-popover-above { 8 | transform-origin: right bottom; 9 | 10 | [dir='rtl'] & { 11 | transform-origin: left bottom; 12 | } 13 | } 14 | 15 | &.sat-popover-before.sat-popover-center { 16 | transform-origin: right center; 17 | 18 | [dir='rtl'] & { 19 | transform-origin: left center; 20 | } 21 | } 22 | 23 | &.sat-popover-before.sat-popover-below { 24 | transform-origin: right top; 25 | 26 | [dir='rtl'] & { 27 | transform-origin: left top; 28 | } 29 | } 30 | 31 | &.sat-popover-center.sat-popover-above { 32 | transform-origin: center bottom; 33 | } 34 | 35 | &.sat-popover-center.sat-popover-below { 36 | transform-origin: center top; 37 | } 38 | 39 | &.sat-popover-after.sat-popover-above { 40 | transform-origin: left bottom; 41 | 42 | [dir='rtl'] & { 43 | transform-origin: right bottom; 44 | } 45 | } 46 | 47 | &.sat-popover-after.sat-popover-center { 48 | transform-origin: left center; 49 | 50 | [dir='rtl'] & { 51 | transform-origin: right center; 52 | } 53 | } 54 | 55 | &.sat-popover-after.sat-popover-below { 56 | transform-origin: left top; 57 | 58 | [dir='rtl'] & { 59 | transform-origin: right top; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/demo/app/action-api/action-api.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { MatCardModule } from '@angular/material/card'; 3 | import { SatPopoverModule } from '../../../lib/public_api'; 4 | 5 | @Component({ 6 | imports: [MatCardModule, SatPopoverModule], 7 | selector: 'demo-action-api', 8 | styleUrls: ['./action-api.component.scss'], 9 | template: ` 10 | 11 | Action API 12 | 13 |
W
14 | 15 |
16 |
17 |
Messages: 12
18 |
Friends since: 12/21/2012
19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | ` 32 | }) 33 | export class DemoActionAPIComponent {} 34 | -------------------------------------------------------------------------------- /src/lib/popover/popover.errors.ts: -------------------------------------------------------------------------------- 1 | import { VALID_HORIZ_ALIGN, VALID_VERT_ALIGN, VALID_SCROLL } from './types'; 2 | 3 | export function getUnanchoredPopoverError(): Error { 4 | return Error('SatPopover does not have an anchor.'); 5 | } 6 | 7 | export function getInvalidPopoverAnchorError(): Error { 8 | return Error('SatPopover#anchor must be an instance of SatPopoverAnchor, ElementRef, or HTMLElement.'); 9 | } 10 | 11 | export function getInvalidPopoverError(): Error { 12 | return Error('SatPopoverAnchor#satPopoverAnchor must be an instance of SatPopover.'); 13 | } 14 | 15 | export function getInvalidSatPopoverAnchorError(): Error { 16 | return Error( 17 | `SatPopoverAnchor must be associated with a ` + 18 | `SatPopover component. ` + 19 | `Examples: or ` + 20 | ` 45 | 46 | 47 |
Scroll the page to observe behavior.
48 |
49 | 50 | 51 | ` 52 | }) 53 | export class DemoScrollStrategiesComponent { 54 | strategy = 'reposition'; 55 | 56 | scrollOptions = [ 57 | { value: 'noop', name: 'Do nothing' }, 58 | { value: 'block', name: 'Block scrolling' }, 59 | { value: 'reposition', name: 'Reposition on scroll' }, 60 | { value: 'close', name: 'Close on scroll' }, 61 | { value: 'rugrats', name: 'Invalid option' } 62 | ]; 63 | } 64 | -------------------------------------------------------------------------------- /src/demo/app/anchor-reuse/anchor-reuse.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Component, ViewChild } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatCardModule } from '@angular/material/card'; 6 | import { MatRadioModule } from '@angular/material/radio'; 7 | import { MatSlideToggleModule } from '@angular/material/slide-toggle'; 8 | import { SatPopoverModule, SatPopoverComponent } from '../../../lib/public_api'; 9 | 10 | @Component({ 11 | imports: [ 12 | CommonModule, 13 | FormsModule, 14 | MatButtonModule, 15 | MatCardModule, 16 | MatRadioModule, 17 | MatSlideToggleModule, 18 | SatPopoverModule 19 | ], 20 | selector: 'demo-anchor-reuse', 21 | styleUrls: ['anchor-reuse.component.scss'], 22 | template: ` 23 | 24 | Anchor Reuse 25 | 26 | Active Popover: 27 | 28 | A 29 | B 30 | 31 | 32 |
33 | 34 | Show Anchor 35 | 36 |
37 | 38 | 46 | 47 |
A
48 |
B
49 |
50 |
51 | ` 52 | }) 53 | export class DemoAnchorReuseComponent implements AfterViewInit { 54 | @ViewChild('a') aPopover: SatPopoverComponent; 55 | @ViewChild('b') bPopover: SatPopoverComponent; 56 | 57 | activePopover = 'a'; 58 | showAnchor = false; 59 | 60 | getActivePopover(): SatPopoverComponent { 61 | return this.activePopover === 'a' ? this.aPopover : this.bPopover; 62 | } 63 | 64 | ngAfterViewInit() { 65 | // Wait for SatPopover references before showing the button 66 | setTimeout(() => { 67 | this.showAnchor = true; 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tools/prepare-package.ts: -------------------------------------------------------------------------------- 1 | import pc from 'picocolors'; 2 | import { readFileSync, writeFileSync, copyFileSync } from 'fs'; 3 | import { 4 | DIST_PACKAGE_PATH, 5 | SOURCE_PACKAGE_PATH, 6 | PEER_DEPENDENCIES, 7 | PACKAGE_PROPERTIES, 8 | DIST_README_PATH, 9 | SOURCE_README_PATH 10 | } from './constants'; 11 | 12 | interface PackageData { 13 | version: string; 14 | dependencies: { [key: string]: string }; 15 | properties: { [key: string]: any }; 16 | } 17 | 18 | /** Pull required data from the source package.json. */ 19 | function getSourceData(dependencyKeys: string[], propertyKeys: string[]): PackageData { 20 | const pick = (obj, props) => Object.assign({}, ...props.map((prop) => ({ [prop]: obj[prop] }))); 21 | const src = JSON.parse(readFileSync(SOURCE_PACKAGE_PATH, 'utf8')); 22 | 23 | // package version 24 | const { version } = src; 25 | 26 | // dependencies 27 | const allDependencies = { ...src.dependencies, ...src.devDependencies, ...src.peerDependencies }; 28 | const dependencies = pick(allDependencies, dependencyKeys); 29 | 30 | // generic properties 31 | const properties = pick(src, propertyKeys); 32 | 33 | return { version, dependencies, properties }; 34 | } 35 | 36 | /** Override the dist package.json with the given data. */ 37 | function replaceDistData(properties: { [key: string]: any }): void { 38 | const dist = JSON.parse(readFileSync(DIST_PACKAGE_PATH, 'utf8')); 39 | const out = { ...dist, ...properties }; 40 | writeFileSync(DIST_PACKAGE_PATH, JSON.stringify(out, null, 2)); 41 | } 42 | 43 | /** Replace values from the src package to the dist. */ 44 | function replacePackageValues(): void { 45 | console.log(pc.cyan('Overwriting package.json properties in dist')); 46 | const src = getSourceData(PEER_DEPENDENCIES, PACKAGE_PROPERTIES); 47 | for (const [packageName, packageVersion] of Object.entries(src.dependencies)) { 48 | const versionParts = packageVersion.replace(/^[\^~]/, '~').split('.'); 49 | // Widen version constraints so we only release on major @angular changes 50 | src.dependencies[packageName] = versionParts[0]; 51 | } 52 | return replaceDistData({ 53 | version: src.version, 54 | peerDependencies: src.dependencies, 55 | ...src.properties 56 | }); 57 | } 58 | 59 | /** Copy README to dist. */ 60 | function copyReadme(): void { 61 | console.log(pc.cyan('Copying README.md to dist package')); 62 | return copyFileSync(SOURCE_README_PATH, DIST_README_PATH); 63 | } 64 | 65 | replacePackageValues(); 66 | copyReadme(); 67 | -------------------------------------------------------------------------------- /src/demo/app/speed-dial/speed-dial.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatIconModule } from '@angular/material/icon'; 6 | import { SatPopoverModule } from '../../../lib/public_api'; 7 | import { trigger, state, style, animate, transition, query } from '@angular/animations'; 8 | 9 | @Component({ 10 | imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, SatPopoverModule], 11 | selector: 'demo-speed-dial', 12 | styleUrls: ['./speed-dial.component.scss'], 13 | animations: [ 14 | trigger('spinInOut', [ 15 | state('in', style({ transform: 'rotate(0)', opacity: '1' })), 16 | transition(':enter', [style({ transform: 'rotate(-180deg)', opacity: '0' }), animate('150ms ease')]), 17 | transition(':leave', [animate('150ms ease', style({ transform: 'rotate(180deg)', opacity: '0' }))]) 18 | ]), 19 | trigger('preventInitialAnimation', [transition(':enter', [query(':enter', [], { optional: true })])]) 20 | ], 21 | template: ` 22 | 23 | 34 | 35 | 36 | 37 |
38 | 39 | 50 | 51 | 52 |
53 | {{ a.name }} 54 |
55 |
56 |
57 |
58 |
59 | ` 60 | }) 61 | export class DemoSpeedDialComponent { 62 | actions = [ 63 | { name: 'Add attachment', icon: 'attachment' }, 64 | { name: 'New folder', icon: 'folder' }, 65 | { name: 'New shared folder', icon: 'folder_shared' } 66 | ]; 67 | } 68 | -------------------------------------------------------------------------------- /src/demo/app/tooltip/tooltip.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Component, ViewChild } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { MatCardModule } from '@angular/material/card'; 4 | import { SatPopoverComponent, SatPopoverModule } from '../../../lib/public_api'; 5 | import { Subject, of } from 'rxjs'; 6 | import { switchMap, takeUntil, delay } from 'rxjs/operators'; 7 | 8 | @Component({ 9 | imports: [CommonModule, MatCardModule, SatPopoverModule], 10 | selector: 'demo-tooltip', 11 | styleUrls: ['./tooltip.component.scss'], 12 | template: ` 13 | 14 | Tooltip 15 | 16 | 17 |
24 | Hover Me (instant) 25 |
26 | 27 |
28 | Multi-line
29 | Tooltip 30 |
31 |
32 | 33 | 34 |
41 | Hover Me (1000ms delay) 42 |
43 | 44 |
A tooltip that's slow to open
45 |
46 | 47 | 48 |
49 | Hover 50 | this text 51 | for 500ms 52 |
53 | 54 |
This tooltip uses the SatPopoverHoverDirective
55 |
56 |
57 |
58 | ` 59 | }) 60 | export class DemoTooltipComponent implements AfterViewInit { 61 | @ViewChild('poDelayed') delayed: SatPopoverComponent; 62 | 63 | mouseenter = new Subject(); 64 | mouseleave = new Subject(); 65 | 66 | ngAfterViewInit() { 67 | this.mouseenter 68 | .pipe(switchMap(() => of(null).pipe(delay(1000), takeUntil(this.mouseleave)))) 69 | .subscribe(() => this.delayed.open()); 70 | 71 | this.mouseleave.subscribe(() => this.delayed.close()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/demo/app/demo.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, HostBinding } from '@angular/core'; 3 | import { DemoActionAPIComponent } from './action-api/action-api.component'; 4 | import { DemoAnchorReuseComponent } from './anchor-reuse/anchor-reuse.component'; 5 | import { DemoFocusComponent } from './focus/focus.component'; 6 | import { DemoInteractiveCloseComponent } from './interactive-close/interactive-close.component'; 7 | import { DemoPositioningComponent } from './positioning/positioning.component'; 8 | import { DemoScrollStrategiesComponent } from './scroll-strategies/scroll-strategies.component'; 9 | import { DemoSelectTriggerComponent } from './select-trigger/select-trigger.component'; 10 | import { DemoSpeedDialComponent } from './speed-dial/speed-dial.component'; 11 | import { DemoTooltipComponent } from './tooltip/tooltip.component'; 12 | import { DemoTransitionsComponent } from './transitions/transitions.component'; 13 | import { MatButtonModule } from '@angular/material/button'; 14 | import { MatToolbarModule } from '@angular/material/toolbar'; 15 | import { environment } from '../environments/environment'; 16 | 17 | @Component({ 18 | imports: [ 19 | CommonModule, 20 | DemoActionAPIComponent, 21 | DemoAnchorReuseComponent, 22 | DemoFocusComponent, 23 | DemoInteractiveCloseComponent, 24 | DemoPositioningComponent, 25 | DemoScrollStrategiesComponent, 26 | DemoSelectTriggerComponent, 27 | DemoSpeedDialComponent, 28 | DemoTooltipComponent, 29 | DemoTransitionsComponent, 30 | MatButtonModule, 31 | MatToolbarModule 32 | ], 33 | selector: 'demo-root', 34 | styleUrls: ['./demo.component.scss'], 35 | template: ` 36 | 37 | 38 | @ncstate/sat-popover {{ version }} 39 | 40 | 43 | 46 | 47 | 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
60 | ` 61 | }) 62 | export class DemoRootComponent { 63 | direction = 'ltr'; 64 | showContent = true; 65 | version = environment.version; 66 | @HostBinding('class.mat-app-background') background = true; 67 | } 68 | -------------------------------------------------------------------------------- /src/demo/app/interactive-close/interactive-close.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ESCAPE } from '@angular/cdk/keycodes'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatCardModule } from '@angular/material/card'; 7 | import { MatCheckboxModule } from '@angular/material/checkbox'; 8 | import { SatPopoverComponent, SatPopoverModule } from '../../../lib/public_api'; 9 | import { Subject, merge } from 'rxjs'; 10 | import { filter, takeUntil } from 'rxjs/operators'; 11 | 12 | @Component({ 13 | imports: [CommonModule, FormsModule, MatButtonModule, MatCardModule, MatCheckboxModule, SatPopoverModule], 14 | selector: 'demo-interactive-close', 15 | styleUrls: ['./interactive-close.component.scss'], 16 | template: ` 17 | 18 | Interactive Close Behavior 19 | 20 | Allow Interactive Closing 21 |

22 | You must select one of the options in the popover. Pressing ESC or clicking outside the popover will not close 23 | it. 24 |

25 |

26 | You don't necessarily need to select an option. You can press ESC or click on the backdrop to close the 27 | popover. 28 |

29 | 32 |
33 |
34 | 35 | 43 |
44 |

Please select one of the following:

45 | 46 | 47 |
48 |
49 | ` 50 | }) 51 | export class DemoInteractiveCloseComponent implements AfterViewInit, OnDestroy { 52 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 53 | @ViewChild('optionsPanel') optionsPanel: ElementRef; 54 | 55 | showError = false; 56 | interactiveClose = false; 57 | 58 | private _onDestroy = new Subject(); 59 | 60 | ngAfterViewInit() { 61 | const escape$ = this.popover.overlayKeydown.pipe(filter((e) => e.keyCode === ESCAPE)); 62 | const backdrop$ = this.popover.backdropClicked; 63 | 64 | merge(escape$, backdrop$) 65 | .pipe(takeUntil(this._onDestroy)) 66 | .subscribe(() => this._showAlert()); 67 | } 68 | 69 | ngOnDestroy() { 70 | this._onDestroy.next(); 71 | this._onDestroy.complete(); 72 | } 73 | 74 | private _showAlert() { 75 | this.showError = true; 76 | // make the options panel shake 77 | this.optionsPanel.nativeElement.classList.add('shake'); 78 | setTimeout(() => this.optionsPanel.nativeElement.classList.remove('shake'), 300); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/demo/app/transitions/transitions.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatCardModule } from '@angular/material/card'; 5 | import { MatFormFieldModule } from '@angular/material/form-field'; 6 | import { MatInputModule } from '@angular/material/input'; 7 | import { SatPopoverModule } from '../../../lib/public_api'; 8 | 9 | @Component({ 10 | imports: [CommonModule, FormsModule, MatCardModule, MatFormFieldModule, MatInputModule, SatPopoverModule], 11 | selector: 'demo-transitions', 12 | styleUrls: ['./transitions.component.scss'], 13 | template: ` 14 | 15 | Custom Transitions 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Initial scale value for open animation. 27 | 28 | 29 | 30 | End scale value for close animation. 31 | 32 |
33 | 34 |
35 |
36 | {{ indicator.name }} 37 |
38 |
39 | 40 |
48 | 49 | 63 |
Hello!
64 |
65 |
66 |
67 | ` 68 | }) 69 | export class DemoTransitionsComponent { 70 | openTransition = '2000ms ease'; 71 | closeTransition = '2000ms ease'; 72 | startAtScale = 0.3; 73 | endAtScale = 0.5; 74 | 75 | callbackIndicators = [ 76 | { name: 'opened', active: false }, 77 | { name: 'closed', active: false }, 78 | { name: 'afterOpen', active: false }, 79 | { name: 'afterClose', active: false } 80 | ]; 81 | 82 | showCallback(name) { 83 | const callback = this.callbackIndicators.find((i) => i.name === name); 84 | 85 | // Flash the callback indicator 86 | callback.active = true; 87 | setTimeout(() => (callback.active = false), 100); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ncstate/sat-popover", 3 | "version": "15.0.0", 4 | "license": "MIT", 5 | "engines": { 6 | "npm": ">=11.3.0", 7 | "node": ">=22.12.0" 8 | }, 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "demo": "ng serve demo", 12 | "build": "ng build popover && tsx tools/prepare-package.ts", 13 | "build:prod": "ng build popover --configuration production && tsx tools/prepare-package.ts", 14 | "build:demo": "ng build demo --configuration production --base-href=\"/popover/\"", 15 | "gh-pages": "ngh --dir dist/demo", 16 | "lint": "ng lint popover", 17 | "format": "prettier --write *", 18 | "test": "ng test popover", 19 | "test:once": "ng test popover --watch=false", 20 | "release": "tsx tools/release.ts" 21 | }, 22 | "private": true, 23 | "keywords": [ 24 | "angular", 25 | "component", 26 | "popover", 27 | "popup", 28 | "popper", 29 | "overlay" 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/ncstate-sat/popover.git" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/ncstate-sat/popover/issues" 37 | }, 38 | "homepage": "https://github.com/ncstate-sat/popover#readme", 39 | "devDependencies": { 40 | "@angular-eslint/builder": "~20.0.0", 41 | "@angular-eslint/eslint-plugin": "~20.0.0", 42 | "@angular-eslint/eslint-plugin-template": "~20.0.0", 43 | "@angular-eslint/schematics": "~20.0.0", 44 | "@angular-eslint/template-parser": "~20.0.0", 45 | "@angular/animations": "~20.0.2", 46 | "@angular/build": "^20.0.1", 47 | "@angular/cdk": "~20.0.2", 48 | "@angular/cli": "~20.0.1", 49 | "@angular/common": "~20.3.14", 50 | "@angular/compiler": "~20.0.2", 51 | "@angular/compiler-cli": "~20.0.2", 52 | "@angular/core": "~20.0.2", 53 | "@angular/forms": "~20.0.2", 54 | "@angular/language-service": "~20.0.2", 55 | "@angular/material": "~20.0.2", 56 | "@angular/platform-browser": "~20.0.2", 57 | "@angular/platform-browser-dynamic": "~20.0.2", 58 | "@angular/router": "~20.0.2", 59 | "@types/jasmine": "~5.1.8", 60 | "@types/jasminewd2": "^2.0.13", 61 | "@types/node": "^24.0.0", 62 | "@typescript-eslint/eslint-plugin": "8.34.0", 63 | "@typescript-eslint/parser": "8.34.0", 64 | "angular-cli-ghpages": "^2.0.3", 65 | "core-js": "^3.43.0", 66 | "eslint": "~9.28.0", 67 | "jasmine-core": "~5.8.0", 68 | "jasmine-spec-reporter": "~7.0.0", 69 | "karma": "^6.4.4", 70 | "karma-chrome-launcher": "~3.2.0", 71 | "karma-coverage-istanbul-reporter": "^3.0.3", 72 | "karma-jasmine": "~5.1.0", 73 | "karma-jasmine-html-reporter": "^2.1.0", 74 | "ng-packagr": "^20.0.0", 75 | "picocolors": "1.1.1", 76 | "prettier": "^3.5.3", 77 | "rxjs": "^7.8.2", 78 | "tsx": "^4.20.3", 79 | "typescript": "~5.8.3", 80 | "zone.js": "~0.15.1" 81 | }, 82 | "dependencies": { 83 | "tslib": "^2.8.1" 84 | }, 85 | "husky": { 86 | "hooks": { 87 | "pre-commit": "lint-staged" 88 | } 89 | }, 90 | "lint-staged": { 91 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 92 | "prettier --write" 93 | ] 94 | }, 95 | "overrides": { 96 | "gh-pages": "^5.0.0" 97 | }, 98 | "volta": { 99 | "node": "22.12.0", 100 | "npm": "11.3.0" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/demo/polyfills.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:max-line-length */ 2 | /** 3 | * This file includes polyfills needed by Angular and is loaded before the app. 4 | * You can add your own extra polyfills to this file. 5 | * 6 | * This file is divided into 2 sections: 7 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 8 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 9 | * file. 10 | * 11 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 12 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 13 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 14 | * 15 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 16 | */ 17 | 18 | /*************************************************************************************************** 19 | * BROWSER POLYFILLS 20 | */ 21 | 22 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 23 | // import 'core-js/es6/symbol'; 24 | // import 'core-js/es6/object'; 25 | // import 'core-js/es6/function'; 26 | // import 'core-js/es6/parse-int'; 27 | // import 'core-js/es6/parse-float'; 28 | // import 'core-js/es6/number'; 29 | // import 'core-js/es6/math'; 30 | // import 'core-js/es6/string'; 31 | // import 'core-js/es6/date'; 32 | // import 'core-js/es6/array'; 33 | // import 'core-js/es6/regexp'; 34 | // import 'core-js/es6/map'; 35 | // import 'core-js/es6/weak-map'; 36 | // import 'core-js/es6/set'; 37 | 38 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 39 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 40 | 41 | /** IE10 and IE11 requires the following for the Reflect API. */ 42 | // import 'core-js/es6/reflect'; 43 | 44 | /** Evergreen browsers require these. **/ 45 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 46 | import 'core-js/es7/reflect'; 47 | 48 | /** 49 | * Web Animations `@angular/platform-browser/animations` 50 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 51 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 52 | **/ 53 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 54 | 55 | /** 56 | * By default, zone.js will patch all possible macroTask and DomEvents 57 | * user can disable parts of macroTask/DomEvents patch by setting following flags 58 | */ 59 | 60 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 61 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 62 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 63 | 64 | /* 65 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 66 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 67 | */ 68 | // (window as any).__Zone_enable_cross_context_check = true; 69 | 70 | /*************************************************************************************************** 71 | * Zone JS is required by default for Angular itself. 72 | */ 73 | import 'zone.js'; // Included with Angular CLI. 74 | 75 | /*************************************************************************************************** 76 | * APPLICATION IMPORTS 77 | */ 78 | -------------------------------------------------------------------------------- /src/demo/app/positioning/positioning.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatCardModule } from '@angular/material/card'; 6 | import { MatCheckboxModule } from '@angular/material/checkbox'; 7 | import { MatFormFieldModule } from '@angular/material/form-field'; 8 | import { MatInputModule } from '@angular/material/input'; 9 | import { MatSelectModule } from '@angular/material/select'; 10 | import { SatPopoverModule } from '../../../lib/public_api'; 11 | 12 | @Component({ 13 | imports: [ 14 | CommonModule, 15 | FormsModule, 16 | MatButtonModule, 17 | MatCardModule, 18 | MatCheckboxModule, 19 | MatFormFieldModule, 20 | MatInputModule, 21 | MatSelectModule, 22 | SatPopoverModule 23 | ], 24 | selector: 'demo-positioning', 25 | styleUrls: ['./positioning.component.scss'], 26 | template: ` 27 | 28 | Positioning 29 | 30 |
31 | 32 | 33 | Before 34 | Start 35 | Center 36 | End 37 | After 38 | Octopus 39 | 40 | 41 | 42 | 43 | 44 | Above 45 | Start 46 | Center 47 | End 48 | Below 49 | Aardvark 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | forceAlignment 58 | 59 | lockAlignment 60 |
61 | 62 | 72 |
73 | 74 | 83 |
Nifty
84 |
85 |
86 | ` 87 | }) 88 | export class DemoPositioningComponent { 89 | horizontalAlign = 'after'; 90 | verticalAlign = 'center'; 91 | margin = 0; 92 | forceAlignment = false; 93 | lockAlignment = false; 94 | } 95 | -------------------------------------------------------------------------------- /src/demo/app/focus/focus.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ViewChild } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormGroup, FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatCardModule } from '@angular/material/card'; 6 | import { MatCheckboxModule } from '@angular/material/checkbox'; 7 | import { MatDatepickerModule } from '@angular/material/datepicker'; 8 | import { MatFormFieldModule } from '@angular/material/form-field'; 9 | import { MatIconModule } from '@angular/material/icon'; 10 | import { MatInputModule } from '@angular/material/input'; 11 | import { SatPopoverModule, SatPopoverComponent } from '../../../lib/public_api'; 12 | import { provideNativeDateAdapter } from '@angular/material/core'; 13 | 14 | @Component({ 15 | imports: [ 16 | CommonModule, 17 | FormsModule, 18 | MatButtonModule, 19 | MatCardModule, 20 | MatCheckboxModule, 21 | MatDatepickerModule, 22 | MatFormFieldModule, 23 | MatIconModule, 24 | MatInputModule, 25 | SatPopoverModule, 26 | ReactiveFormsModule 27 | ], 28 | providers: [provideNativeDateAdapter()], 29 | selector: 'demo-focus', 30 | styleUrls: ['./focus.component.scss'], 31 | template: ` 32 | 33 | Focus Behavior 34 | 35 |
36 | Auto Focus 37 | Restore Focus 38 |
39 | 40 |
41 | 44 |

First Name: {{ form.value.first }}

45 |

Last Name: {{ form.value.last }}

46 |

Birth Date: {{ form.value.birthDate | date }}

47 |
48 | 49 | 57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 72 | 73 | 74 | 75 |
76 |
77 |
78 |
79 | ` 80 | }) 81 | export class DemoFocusComponent { 82 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 83 | autoFocus = true; 84 | restoreFocus = true; 85 | form: FormGroup; 86 | 87 | constructor(fb: FormBuilder) { 88 | this.form = fb.group({ 89 | first: 'Monty', 90 | last: 'Python', 91 | birthDate: new Date(1969, 9, 5) 92 | }); 93 | } 94 | 95 | closeOnEnter(event: KeyboardEvent) { 96 | if (event.code === 'Enter') { 97 | this.popover.close(); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "cli": { 6 | "packageManager": "npm", 7 | "analytics": false 8 | }, 9 | "projects": { 10 | "popover": { 11 | "root": "src/lib", 12 | "sourceRoot": "src/lib/popover", 13 | "projectType": "library", 14 | "prefix": "sat", 15 | "architect": { 16 | "build": { 17 | "builder": "@angular/build:ng-packagr", 18 | "options": { 19 | "tsConfig": "src/lib/tsconfig.lib.json", 20 | "project": "src/lib/ng-package.json" 21 | }, 22 | "configurations": { 23 | "production": { 24 | "project": "src/lib/ng-package.prod.json", 25 | "tsConfig": "src/lib/tsconfig.lib.prod.json" 26 | } 27 | } 28 | }, 29 | "test": { 30 | "builder": "@angular/build:karma", 31 | "options": { 32 | "main": "src/lib/test.ts", 33 | "tsConfig": "src/lib/tsconfig.spec.json", 34 | "karmaConfig": "src/lib/karma.conf.js", 35 | "polyfills": ["zone.js", "zone.js/testing"] 36 | } 37 | }, 38 | "lint": { 39 | "builder": "@angular-eslint/builder:lint", 40 | "options": { 41 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 42 | } 43 | } 44 | } 45 | }, 46 | "demo": { 47 | "root": "src/demo", 48 | "sourceRoot": "src/demo", 49 | "projectType": "application", 50 | "prefix": "", 51 | "schematics": {}, 52 | "architect": { 53 | "build": { 54 | "builder": "@angular/build:application", 55 | "options": { 56 | "outputPath": { 57 | "base": "dist/demo" 58 | }, 59 | "index": "src/demo/index.html", 60 | "polyfills": ["src/demo/polyfills.ts"], 61 | "tsConfig": "src/demo/tsconfig.app.json", 62 | "assets": ["src/demo/favicon.ico", "src/demo/assets"], 63 | "styles": ["src/demo/styles.scss"], 64 | "scripts": [], 65 | "extractLicenses": false, 66 | "sourceMap": true, 67 | "optimization": false, 68 | "namedChunks": true, 69 | "browser": "src/demo/main.ts" 70 | }, 71 | "configurations": { 72 | "production": { 73 | "budgets": [ 74 | { 75 | "type": "anyComponentStyle", 76 | "maximumWarning": "6kb" 77 | } 78 | ], 79 | "fileReplacements": [ 80 | { 81 | "replace": "src/demo/environments/environment.ts", 82 | "with": "src/demo/environments/environment.prod.ts" 83 | } 84 | ], 85 | "optimization": true, 86 | "outputHashing": "all", 87 | "sourceMap": false, 88 | "namedChunks": false, 89 | "extractLicenses": true 90 | } 91 | }, 92 | "defaultConfiguration": "" 93 | }, 94 | "serve": { 95 | "builder": "@angular/build:dev-server", 96 | "options": { 97 | "buildTarget": "demo:build" 98 | }, 99 | "configurations": { 100 | "production": { 101 | "buildTarget": "demo:build:production" 102 | } 103 | } 104 | }, 105 | "extract-i18n": { 106 | "builder": "@angular/build:extract-i18n", 107 | "options": { 108 | "buildTarget": "demo:build" 109 | } 110 | }, 111 | "test": { 112 | "builder": "@angular/build:karma", 113 | "options": { 114 | "main": "src/demo/test.ts", 115 | "polyfills": "src/demo/polyfills.ts", 116 | "tsConfig": "src/demo/tsconfig.spec.json", 117 | "karmaConfig": "src/demo/karma.conf.js", 118 | "styles": ["styles.scss"], 119 | "scripts": [], 120 | "assets": ["src/demo/favicon.ico", "src/demo/assets"] 121 | } 122 | }, 123 | "lint": { 124 | "builder": "@angular-eslint/builder:lint", 125 | "options": { 126 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 127 | } 128 | } 129 | } 130 | } 131 | }, 132 | "schematics": { 133 | "@schematics/angular:component": { 134 | "type": "component" 135 | }, 136 | "@schematics/angular:directive": { 137 | "type": "directive" 138 | }, 139 | "@schematics/angular:service": { 140 | "type": "service" 141 | }, 142 | "@schematics/angular:guard": { 143 | "typeSeparator": "." 144 | }, 145 | "@schematics/angular:interceptor": { 146 | "typeSeparator": "." 147 | }, 148 | "@schematics/angular:module": { 149 | "typeSeparator": "." 150 | }, 151 | "@schematics/angular:pipe": { 152 | "typeSeparator": "." 153 | }, 154 | "@schematics/angular:resolver": { 155 | "typeSeparator": "." 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Popover Component for Angular 2 | 3 | [![npm version](https://badge.fury.io/js/%40ncstate%2Fsat-popover.svg)](https://badge.fury.io/js/%40ncstate%2Fsat-popover) 4 | [![Build Status](https://travis-ci.org/ncstate-sat/popover.svg?branch=master)](https://travis-ci.org/ncstate-sat/popover) 5 | 6 | [Demo](https://stackblitz.com/edit/ncstate-sat-popover-examples) | 7 | [StackBlitz Template](https://stackblitz.com/edit/ncstate-sat-popover-issues) | 8 | [Development App](https://ncstate-sat.github.io/popover/) 9 | 10 | ## Installation 11 | 12 | `sat-popover` has a peer dependency on the Angular CDK to leverage its overlay API. 13 | 14 | ```bash 15 | npm install --save @ncstate/sat-popover @angular/cdk 16 | ``` 17 | 18 | ```ts 19 | import { AppRootComponent } from './app-root.component'; 20 | import { bootstrapApplication } from '@angular/platform-browser'; 21 | import { importProvidersFrom } from '@angular/core'; 22 | import { provideAnimations, provideNoopAnimations } from '@angular/platform-browser/animations'; 23 | import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; 24 | import { SatPopoverModule } from '@ncstate/sat-popover'; 25 | 26 | bootstrapApplication(AppRootComponent, { 27 | // AppConfig 28 | providers: [ 29 | // If you want the popover animations to work, you must use `provideAnimations` or `provideAnimationsAsync`. 30 | provideAnimationsAsync(), 31 | 32 | // If your application requires animations on immediate load, use `provideAnimations` instead. 33 | // provideAnimations(), 34 | 35 | // If you prefer to not have animations, you can use `provideNoopAnimations`. 36 | // provideNoopAnimations(), 37 | 38 | importProvidersFrom(SatPopoverModule) 39 | ] 40 | }); 41 | ``` 42 | 43 | Finally, import the `SatPopoverModule` as needed to provide the necessary components and directives. 44 | 45 | ```ts 46 | import { Component } from '@angular/core'; 47 | import { SatPopoverAnchorDirective, SatPopoverComponent } from '@ncstate/sat-popover'; 48 | 49 | @Component({ 50 | imports: [ SatPopoverAnchorDirective, SatPopoverComponent ], 51 | selector: 'my-component', 52 | template: ` 53 | 56 | Hello! 57 | ` 58 | }) 59 | ``` 60 | 61 | ## Usage 62 | 63 | ### Getting started 64 | 65 | Wrap any component you want to display in a popover with an `` component. 66 | 67 | ```html 68 | 69 | 70 | 71 | ``` 72 | 73 | Next, apply the `satPopoverAnchor` directive to the element you wish to be the popover anchor and pass the `` component as an argument to the `satPopoverAnchor` directive. 74 | 75 | ```html 76 | 77 | 78 | 79 | 80 | 81 | ``` 82 | 83 | > Note: `hasBackdrop` is explained below 84 | 85 | Alternatively, supply an anchor element to the popover. 86 | 87 | ```html 88 | 89 | 90 | 91 | 92 | 93 | ``` 94 | 95 | ### Alignment 96 | 97 | By default, the popover will appear centered over the anchor. If you instead want the popover 98 | to appear below the anchor: 99 | 100 | ```html 101 | 102 | 103 | 104 | ``` 105 | 106 | You can use the following to align the popover around the anchor: 107 | 108 | | Input | Type | Default | 109 | | ----------------- | --------------------------------------------------- | -------- | 110 | | `horizontalAlign` | 'before' \| 'start' \| 'center' \| 'end' \| 'after' | 'center' | 111 | | `verticalAlign` | 'above' \| 'start' \| 'center' \| 'end' \| 'below' | 'center' | 112 | 113 | For convenience, you can also use `xAlign` and `yAlign` as shorthand for `horizontalAlign` 114 | and `verticalAlign`, respectively. 115 | 116 | By default, if the popover cannot fully fit within the viewport, it will use a fallback 117 | alignment. You can use `forceAlignment` to ensure that the popover always displays 118 | with the alignment you've specified. 119 | 120 | ```html 121 | 122 | 123 | 124 | ``` 125 | 126 | Also by default, as the user scrolls or changes the viewport size, the popover will attempt 127 | to stay within the viewport by using a fallback position (provided `forceAlignment` is not 128 | set). You can use `lockAlignment` to ensure the popover does not change its alignment once 129 | opened. 130 | 131 | ```html 132 | 133 | 134 | 135 | ``` 136 | 137 | ### Opening and closing 138 | 139 | You are in full control of when the popover opens and closes. You can hook into any event or 140 | trigger that fits your application's needs. 141 | 142 | #### `SatPopover` has the following methods and outputs 143 | 144 | | Method | Description | 145 | | ------- | -------------------------------------------- | 146 | | open | Open the popover. | 147 | | close | Close the popover. Optionally takes a value. | 148 | | toggle | Toggle the popover open or closed. | 149 | | isOpen | Get whether the popover is presently open. | 150 | | realign | Realign the popover to the anchor. | 151 | 152 | | Output | Description | 153 | | --------------- | ----------------------------------------------------------------- | 154 | | opened | Emits when the popover is opened. | 155 | | closed | Emits when the popover is closed. | 156 | | afterOpen | Emits when the popover has finished opening. | 157 | | afterClose | Emits when the popover has finished closing. | 158 | | backdropClicked | Emits when the popover's backdrop (if enabled) is clicked. | 159 | | overlayKeydown | Emits when a keydown event is targeted to this popover's overlay. | 160 | 161 | #### `SatPopoverAnchor` has the following properties 162 | 163 | | Property | Description | 164 | | ------------------------- | ------------------------------------------------- | 165 | | popover | A handle to the associated popover. | 166 | | satPopoverAnchor (setter) | An `@Input()` for setting the associated popover. | 167 | | elementRef | The ElementRef for with the anchor. | 168 | | viewContainerRef | The ViewContainerRef for the anchor. | 169 | 170 | ### Focus behavior 171 | 172 | By default, the popover will apply focus to the first tabbable element when opened and trap focus 173 | within the popover until closed. If the popover does not contain any focusable elements, focus 174 | will remain on the most recently focused element. 175 | 176 | You can target a different element for initial focus using the `cdkFocusInitial` attribute. 177 | 178 | To prevent focus from automatically moving into the popover, you can set the `autoFocus` property 179 | to `false`. 180 | 181 | ```html 182 | 183 | 184 | 185 | ``` 186 | 187 | Once the popover is closed, focus will return to the most recently focused element prior to 188 | opening the popover. To disable this, you can set the `restoreFocus` property to `false`. 189 | 190 | ```html 191 | 192 | 193 | 194 | ``` 195 | 196 | Alternatively the `open` method supports an optional `SatPopoverOpenOptions` 197 | object where `autoFocus` and `restoreFocus` options can be set while opening the popover. Note 198 | that these options do no take precendence over the component inputs. For example, if `restoreFocus` 199 | is set to `false` either in the open options or via the component input, focus will not be 200 | restored. 201 | 202 | ```html 203 | 204 | ``` 205 | 206 | ### Backdrop 207 | 208 | You can add a fullscreen backdrop that appears behind the popover when it is open. It prevents 209 | interaction with the rest of the application and will automatically close the popover when 210 | clicked. To add it to your popover, use `hasBackdrop`. 211 | 212 | ```html 213 | 214 | 215 | 216 | ``` 217 | 218 | If used, the default backdrop will be transparent. You can add any custom backdrop class with 219 | `backdropClass`. 220 | 221 | ```html 222 | 223 | 224 | 225 | ``` 226 | 227 | > Note: if you plan on using `mouseenter` and `mouseleave` events to open and close your popover, 228 | > keep in mind that a backdrop will block pointer events once it is open, immediately triggering 229 | > a `mouseleave` event. 230 | 231 | ### Overlay panel 232 | 233 | You can add custom css classes to the overlay panel that wraps the popover. 234 | 235 | ```html 236 | 237 | 238 | 239 | ``` 240 | 241 | ### Interactive closing 242 | 243 | If your popover has a backdrop, it will automatically close when clicked. The popover will also 244 | automatically close when esc is pressed. These two behaviors are wrapped in the 245 | `interactiveClose` property, which defaults to `true`. Set `interactiveClose` to `false` to prevent 246 | the popover from automatically closing on these user interactions. 247 | 248 | ```html 249 | 250 | 251 | 252 | ``` 253 | 254 | If you wish to only disable the automatic esc behavior, you must disable all 255 | interactive close options and then manually react to `backdropClicked` events. 256 | 257 | ```html 258 | 259 | 260 | 261 | ``` 262 | 263 | ### Scrolling 264 | 265 | By default, when a popover is open and the user scrolls the container, the popover will reposition 266 | itself to stay attached to its anchor. You can adjust this behavior with `scrollStrategy`. 267 | 268 | ```html 269 | 270 | 271 | 272 | ``` 273 | 274 | | Strategy | Description | 275 | | -------------- | ------------------------------------------- | 276 | | `'noop'` | Don't update position. | 277 | | `'block'` | Block page scrolling while open. | 278 | | `'reposition'` | Reposition the popover on scroll (default). | 279 | | `'close'` | Close the popover on scroll. | 280 | 281 | > Note: if your popover fails to stay anchored with the `reposition` strategy, you may need to add 282 | > the [`cdkScrollable`](https://material.angular.io/cdk/scrolling/overview) directive to your 283 | > scrolling container. This will ensure scroll events are dispatched to the popover's positioning 284 | > service. 285 | 286 | ### Animations 287 | 288 | By default, the opening and closing animations of a popover are quick with a simple easing curve. 289 | You can modify these animation curves using `openTransition` and `closeTransition`. 290 | 291 | ```html 292 | 293 | 294 | 295 | 296 | ``` 297 | 298 | You can also modify the default transition globally by adding a custom value to the 299 | `DEFAULT_TRANSITION` provider. 300 | 301 | ```ts 302 | import { SatPopoverModule, DEFAULT_TRANSITION } from '@ncstate/sat-popover'; 303 | 304 | @NgModule({ 305 | ... 306 | imports: [ SatPopoverModule ], 307 | providers: [ 308 | { provide: DEFAULT_TRANSITION, useValue: '300ms ease' } 309 | ] 310 | ... 311 | }) 312 | export class AppModule { } 313 | ``` 314 | 315 | Additionally you can modify the scale values for the opening (`startAtScale`) and closing (`endAtScale`) animations. 316 | 317 | ```html 318 | 319 | 320 | 321 | 322 | ``` 323 | 324 | ## Styles 325 | 326 | The `` component only provides styles to affect its own transform origin. It is 327 | the responsibility of the elements you project inside the popover to style themselves. This 328 | includes background color, box shadows, margin offsets, etc. 329 | 330 | ## Add-on behaviors 331 | 332 | ### Hover 333 | 334 | The `SatPopoverHoverDirective` is available as a way to automatically add hover logic to your 335 | anchor with an optional delay. The `SatPopoverHoverDirective` must be used in conjunction 336 | with `SatPopoverAnchor`. 337 | 338 | ```html 339 |
Hover to show tooltip after 1 second
340 | ``` 341 | 342 | ```html 343 |
Hover this text to show tooltip immediately
344 | ``` 345 | -------------------------------------------------------------------------------- /src/lib/popover/popover.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ElementRef, 4 | EventEmitter, 5 | Inject, 6 | Input, 7 | ViewChild, 8 | ViewEncapsulation, 9 | TemplateRef, 10 | OnInit, 11 | Optional, 12 | Output, 13 | Directive, 14 | ViewContainerRef, 15 | AfterViewInit, 16 | DOCUMENT 17 | } from '@angular/core'; 18 | import { AnimationEvent } from '@angular/animations'; 19 | import { CommonModule } from '@angular/common'; 20 | import { ConfigurableFocusTrap, ConfigurableFocusTrapFactory } from '@angular/cdk/a11y'; 21 | import { BooleanInput, coerceBooleanProperty, coerceNumberProperty, NumberInput } from '@angular/cdk/coercion'; 22 | 23 | import { transformPopover } from './popover.animations'; 24 | import { 25 | getUnanchoredPopoverError, 26 | getInvalidHorizontalAlignError, 27 | getInvalidVerticalAlignError, 28 | getInvalidScrollStrategyError, 29 | getInvalidPopoverAnchorError, 30 | getInvalidSatPopoverAnchorError, 31 | getInvalidPopoverError 32 | } from './popover.errors'; 33 | import { 34 | SatPopoverScrollStrategy, 35 | SatPopoverHorizontalAlign, 36 | SatPopoverVerticalAlign, 37 | VALID_SCROLL, 38 | VALID_HORIZ_ALIGN, 39 | VALID_VERT_ALIGN, 40 | SatPopoverOpenOptions 41 | } from './types'; 42 | import { SatPopoverAnchoringService } from './popover-anchoring.service'; 43 | import { DEFAULT_TRANSITION } from './tokens'; 44 | 45 | const DEFAULT_OPEN_ANIMATION_START_SCALE = 0.3; 46 | const DEFAULT_CLOSE_ANIMATION_END_SCALE = 0.5; 47 | 48 | @Directive({ 49 | selector: '[satPopoverAnchor]', 50 | exportAs: 'satPopoverAnchor' 51 | }) 52 | export class SatPopoverAnchorDirective implements AfterViewInit { 53 | @Input('satPopoverAnchor') 54 | get popover() { 55 | return this._popover; 56 | } 57 | set popover(val: SatPopoverComponent) { 58 | if (val instanceof SatPopoverComponent) { 59 | val.anchor = this; 60 | } else { 61 | // when a directive is added with no arguments, 62 | // angular assigns `''` as the argument 63 | if (val !== '') { 64 | throw getInvalidPopoverError(); 65 | } 66 | } 67 | } 68 | 69 | /** @internal */ 70 | _popover: SatPopoverComponent; 71 | 72 | constructor( 73 | public elementRef: ElementRef, 74 | public viewContainerRef: ViewContainerRef 75 | ) {} 76 | 77 | ngAfterViewInit() { 78 | if (!this.popover) { 79 | throw getInvalidSatPopoverAnchorError(); 80 | } 81 | } 82 | } 83 | 84 | @Component({ 85 | animations: [transformPopover], 86 | encapsulation: ViewEncapsulation.None, 87 | imports: [CommonModule], 88 | providers: [SatPopoverAnchoringService], 89 | selector: 'sat-popover', 90 | styleUrls: ['./popover.component.scss'], 91 | templateUrl: './popover.component.html' 92 | }) 93 | export class SatPopoverComponent implements OnInit { 94 | /** Anchor element. */ 95 | @Input() 96 | get anchor() { 97 | return this._anchor; 98 | } 99 | set anchor(val: SatPopoverAnchorDirective | ElementRef | HTMLElement) { 100 | if (val instanceof SatPopoverAnchorDirective) { 101 | val._popover = this; 102 | this._anchoringService.anchor(this, val.viewContainerRef, val.elementRef); 103 | this._anchor = val; 104 | } else if (val instanceof ElementRef || val instanceof HTMLElement) { 105 | this._anchoringService.anchor(this, this._viewContainerRef, val); 106 | this._anchor = val; 107 | } else if (val) { 108 | throw getInvalidPopoverAnchorError(); 109 | } 110 | } 111 | private _anchor: SatPopoverAnchorDirective | ElementRef | HTMLElement; 112 | 113 | /** Alignment of the popover on the horizontal axis. */ 114 | @Input() 115 | get horizontalAlign() { 116 | return this._horizontalAlign; 117 | } 118 | set horizontalAlign(val: SatPopoverHorizontalAlign) { 119 | this._validateHorizontalAlign(val); 120 | if (this._horizontalAlign !== val) { 121 | this._horizontalAlign = val; 122 | this._anchoringService.repositionPopover(); 123 | } 124 | } 125 | private _horizontalAlign: SatPopoverHorizontalAlign = 'center'; 126 | 127 | /** Alignment of the popover on the x axis. Alias for `horizontalAlign`. */ 128 | @Input() 129 | get xAlign() { 130 | return this.horizontalAlign; 131 | } 132 | set xAlign(val: SatPopoverHorizontalAlign) { 133 | this.horizontalAlign = val; 134 | } 135 | 136 | /** Alignment of the popover on the vertical axis. */ 137 | @Input() 138 | get verticalAlign() { 139 | return this._verticalAlign; 140 | } 141 | set verticalAlign(val: SatPopoverVerticalAlign) { 142 | this._validateVerticalAlign(val); 143 | if (this._verticalAlign !== val) { 144 | this._verticalAlign = val; 145 | this._anchoringService.repositionPopover(); 146 | } 147 | } 148 | private _verticalAlign: SatPopoverVerticalAlign = 'center'; 149 | 150 | /** Alignment of the popover on the y axis. Alias for `verticalAlign`. */ 151 | @Input() 152 | get yAlign() { 153 | return this.verticalAlign; 154 | } 155 | set yAlign(val: SatPopoverVerticalAlign) { 156 | this.verticalAlign = val; 157 | } 158 | 159 | /** Whether the popover always opens with the specified alignment. */ 160 | @Input() 161 | get forceAlignment() { 162 | return this._forceAlignment; 163 | } 164 | set forceAlignment(val: BooleanInput) { 165 | const coercedVal = coerceBooleanProperty(val); 166 | if (this._forceAlignment !== coercedVal) { 167 | this._forceAlignment = coercedVal; 168 | this._anchoringService.repositionPopover(); 169 | } 170 | } 171 | private _forceAlignment = false; 172 | 173 | /** 174 | * Whether the popover's alignment is locked after opening. This prevents the popover 175 | * from changing its alignement when scrolling or changing the size of the viewport. 176 | */ 177 | @Input() 178 | get lockAlignment() { 179 | return this._lockAlignment; 180 | } 181 | set lockAlignment(val: BooleanInput) { 182 | const coercedVal = coerceBooleanProperty(val); 183 | if (this._lockAlignment !== coercedVal) { 184 | this._lockAlignment = coerceBooleanProperty(val); 185 | this._anchoringService.repositionPopover(); 186 | } 187 | } 188 | private _lockAlignment = false; 189 | 190 | /** Whether the first focusable element should be focused on open. */ 191 | @Input() 192 | get autoFocus() { 193 | return this._autoFocus && this._autoFocusOverride; 194 | } 195 | set autoFocus(val: BooleanInput) { 196 | this._autoFocus = coerceBooleanProperty(val); 197 | } 198 | private _autoFocus = true; 199 | _autoFocusOverride = true; 200 | 201 | /** Whether the popover should return focus to the previously focused element after closing. */ 202 | @Input() 203 | get restoreFocus() { 204 | return this._restoreFocus && this._restoreFocusOverride; 205 | } 206 | set restoreFocus(val: BooleanInput) { 207 | this._restoreFocus = coerceBooleanProperty(val); 208 | } 209 | private _restoreFocus = true; 210 | _restoreFocusOverride = true; 211 | 212 | /** How the popover should handle scrolling. */ 213 | @Input() 214 | get scrollStrategy() { 215 | return this._scrollStrategy; 216 | } 217 | set scrollStrategy(val: SatPopoverScrollStrategy) { 218 | this._validateScrollStrategy(val); 219 | if (this._scrollStrategy !== val) { 220 | this._scrollStrategy = val; 221 | this._anchoringService.updatePopoverConfig(); 222 | } 223 | } 224 | private _scrollStrategy: SatPopoverScrollStrategy = 'reposition'; 225 | 226 | /** Whether the popover should have a backdrop (includes closing on click). */ 227 | @Input() 228 | get hasBackdrop() { 229 | return this._hasBackdrop; 230 | } 231 | set hasBackdrop(val: BooleanInput) { 232 | this._hasBackdrop = coerceBooleanProperty(val); 233 | } 234 | private _hasBackdrop = false; 235 | 236 | /** Whether the popover should close when the user clicks the backdrop or presses ESC. */ 237 | @Input() 238 | get interactiveClose(): boolean { 239 | return this._interactiveClose; 240 | } 241 | set interactiveClose(val: BooleanInput) { 242 | this._interactiveClose = coerceBooleanProperty(val); 243 | } 244 | private _interactiveClose = true; 245 | 246 | /** Custom transition to use while opening. */ 247 | @Input() 248 | get openTransition() { 249 | return this._openTransition; 250 | } 251 | set openTransition(val: string) { 252 | if (val) { 253 | this._openTransition = val; 254 | } 255 | } 256 | private _openTransition; 257 | 258 | /** Custom transition to use while closing. */ 259 | @Input() 260 | get closeTransition() { 261 | return this._closeTransition; 262 | } 263 | set closeTransition(val: string) { 264 | if (val) { 265 | this._closeTransition = val; 266 | } 267 | } 268 | private _closeTransition; 269 | 270 | /** Scale value at the start of the :enter animation. */ 271 | @Input() 272 | get openAnimationStartAtScale() { 273 | return this._openAnimationStartAtScale; 274 | } 275 | set openAnimationStartAtScale(val: NumberInput) { 276 | const coercedVal = coerceNumberProperty(val); 277 | if (!isNaN(coercedVal)) { 278 | this._openAnimationStartAtScale = coercedVal; 279 | } 280 | } 281 | private _openAnimationStartAtScale = DEFAULT_OPEN_ANIMATION_START_SCALE; 282 | 283 | /** Scale value at the end of the :leave animation */ 284 | @Input() 285 | get closeAnimationEndAtScale() { 286 | return this._closeAnimationEndAtScale; 287 | } 288 | set closeAnimationEndAtScale(val: NumberInput) { 289 | const coercedVal = coerceNumberProperty(val); 290 | if (!isNaN(coercedVal)) { 291 | this._closeAnimationEndAtScale = coercedVal; 292 | } 293 | } 294 | private _closeAnimationEndAtScale = DEFAULT_CLOSE_ANIMATION_END_SCALE; 295 | 296 | /** Optional backdrop class. */ 297 | @Input() backdropClass = ''; 298 | 299 | /** Optional custom class to add to the overlay pane. */ 300 | @Input() panelClass: string | string[] = ''; 301 | 302 | /** Emits when the popover is opened. */ 303 | @Output() opened = new EventEmitter(); 304 | 305 | /** Emits when the popover is closed. */ 306 | @Output() closed = new EventEmitter(); 307 | 308 | /** Emits when the popover has finished opening. */ 309 | @Output() afterOpen = new EventEmitter(); 310 | 311 | /** Emits when the popover has finished closing. */ 312 | @Output() afterClose = new EventEmitter(); 313 | 314 | /** Emits when the backdrop is clicked. */ 315 | @Output() backdropClicked = new EventEmitter(); 316 | 317 | /** Emits when a keydown event is targeted to this popover's overlay. */ 318 | @Output() overlayKeydown = new EventEmitter(); 319 | 320 | /** Reference to template so it can be placed within a portal. */ 321 | @ViewChild(TemplateRef, { static: true }) _templateRef: TemplateRef; 322 | 323 | /** Classes to be added to the popover for setting the correct transform origin. */ 324 | _classList: { [className: string]: boolean } = {}; 325 | 326 | /** Whether the popover is presently open. */ 327 | _open = false; 328 | 329 | _state: 'enter' | 'void' | 'exit' = 'enter'; 330 | 331 | /** @internal */ 332 | _anchoringService: SatPopoverAnchoringService; 333 | 334 | /** Reference to the element to build a focus trap around. */ 335 | @ViewChild('focusTrapElement') 336 | private _focusTrapElement: ElementRef; 337 | 338 | /** Reference to the element that was focused before opening. */ 339 | private _previouslyFocusedElement: HTMLElement; 340 | 341 | /** Reference to a focus trap around the popover. */ 342 | private _focusTrap: ConfigurableFocusTrap; 343 | 344 | constructor( 345 | private _focusTrapFactory: ConfigurableFocusTrapFactory, 346 | _anchoringService: SatPopoverAnchoringService, 347 | private _viewContainerRef: ViewContainerRef, 348 | @Inject(DEFAULT_TRANSITION) private _defaultTransition: string, 349 | @Optional() @Inject(DOCUMENT) private _document = document 350 | ) { 351 | // `@internal` stripping doesn't seem to work if the property is 352 | // declared inside the constructor 353 | this._anchoringService = _anchoringService; 354 | this._openTransition = _defaultTransition; 355 | this._closeTransition = _defaultTransition; 356 | } 357 | 358 | ngOnInit() { 359 | this._setAlignmentClasses(); 360 | } 361 | 362 | /** Open this popover. */ 363 | open(options: SatPopoverOpenOptions = {}): void { 364 | if (this._anchor) { 365 | this._anchoringService.openPopover(options); 366 | return; 367 | } 368 | 369 | throw getUnanchoredPopoverError(); 370 | } 371 | 372 | /** Close this popover. */ 373 | close(value?: unknown): void { 374 | this._anchoringService.closePopover(value); 375 | } 376 | 377 | /** Toggle this popover open or closed. */ 378 | toggle(): void { 379 | this._anchoringService.togglePopover(); 380 | } 381 | 382 | /** Realign the popover to the anchor. */ 383 | realign(): void { 384 | this._anchoringService.realignPopoverToAnchor(); 385 | } 386 | 387 | /** Gets whether the popover is presently open. */ 388 | isOpen(): boolean { 389 | return this._open; 390 | } 391 | 392 | /** Allows programmatically setting a custom anchor. */ 393 | setCustomAnchor(viewContainer: ViewContainerRef, el: ElementRef | HTMLElement): void { 394 | this._anchor = el; 395 | this._anchoringService.anchor(this, viewContainer, el); 396 | } 397 | 398 | /** Gets an animation config with customized (or default) transition values. */ 399 | get state() { 400 | return this._state; 401 | } 402 | get params() { 403 | return { 404 | openTransition: this.openTransition, 405 | closeTransition: this.closeTransition, 406 | startAtScale: this.openAnimationStartAtScale, 407 | endAtScale: this.closeAnimationEndAtScale 408 | }; 409 | } 410 | 411 | /** Callback for when the popover is finished animating in or out. */ 412 | _onAnimationDone({ toState }: AnimationEvent) { 413 | if (toState === 'enter') { 414 | this._trapFocus(); 415 | this.afterOpen.emit(); 416 | } else if (toState === 'exit' || toState === 'void') { 417 | this._restoreFocusAndDestroyTrap(); 418 | this.afterClose.emit(); 419 | } 420 | } 421 | 422 | /** Starts the dialog exit animation. */ 423 | _startExitAnimation(): void { 424 | this._state = 'exit'; 425 | } 426 | /** Apply alignment classes based on alignment inputs. */ 427 | _setAlignmentClasses(horizAlign = this.horizontalAlign, vertAlign = this.verticalAlign) { 428 | this._classList['sat-popover-before'] = horizAlign === 'before' || horizAlign === 'end'; 429 | this._classList['sat-popover-after'] = horizAlign === 'after' || horizAlign === 'start'; 430 | 431 | this._classList['sat-popover-above'] = vertAlign === 'above' || vertAlign === 'end'; 432 | this._classList['sat-popover-below'] = vertAlign === 'below' || vertAlign === 'start'; 433 | 434 | this._classList['sat-popover-center'] = horizAlign === 'center' || vertAlign === 'center'; 435 | } 436 | 437 | /** Move the focus inside the focus trap and remember where to return later. */ 438 | private _trapFocus(): void { 439 | this._savePreviouslyFocusedElement(); 440 | 441 | // There won't be a focus trap element if the close animation starts before open finishes 442 | if (!this._focusTrapElement) { 443 | return; 444 | } 445 | 446 | if (!this._focusTrap && this._focusTrapElement) { 447 | this._focusTrap = this._focusTrapFactory.create(this._focusTrapElement.nativeElement); 448 | } 449 | 450 | if (this.autoFocus) { 451 | this._focusTrap.focusInitialElementWhenReady(); 452 | } 453 | } 454 | 455 | /** Restore focus to the element focused before the popover opened. Also destroy trap. */ 456 | private _restoreFocusAndDestroyTrap(): void { 457 | const toFocus = this._previouslyFocusedElement; 458 | 459 | // Must check active element is focusable for IE sake 460 | if (toFocus && 'focus' in toFocus && this.restoreFocus) { 461 | this._previouslyFocusedElement.focus(); 462 | } 463 | 464 | this._previouslyFocusedElement = null; 465 | 466 | if (this._focusTrap) { 467 | this._focusTrap.destroy(); 468 | this._focusTrap = undefined; 469 | } 470 | } 471 | 472 | /** Save a reference to the element focused before the popover was opened. */ 473 | private _savePreviouslyFocusedElement(): void { 474 | if (this._document) { 475 | this._previouslyFocusedElement = this._document.activeElement as HTMLElement; 476 | } 477 | } 478 | 479 | /** Throws an error if the alignment is not a valid horizontalAlign. */ 480 | private _validateHorizontalAlign(pos: SatPopoverHorizontalAlign): void { 481 | if (VALID_HORIZ_ALIGN.indexOf(pos) === -1) { 482 | throw getInvalidHorizontalAlignError(pos); 483 | } 484 | } 485 | 486 | /** Throws an error if the alignment is not a valid verticalAlign. */ 487 | private _validateVerticalAlign(pos: SatPopoverVerticalAlign): void { 488 | if (VALID_VERT_ALIGN.indexOf(pos) === -1) { 489 | throw getInvalidVerticalAlignError(pos); 490 | } 491 | } 492 | 493 | /** Throws an error if the scroll strategy is not a valid strategy. */ 494 | private _validateScrollStrategy(strategy: SatPopoverScrollStrategy): void { 495 | if (VALID_SCROLL.indexOf(strategy) === -1) { 496 | throw getInvalidScrollStrategyError(strategy); 497 | } 498 | } 499 | } 500 | -------------------------------------------------------------------------------- /src/lib/popover/popover-anchoring.service.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef, Injectable, NgZone, OnDestroy, Optional, ViewContainerRef } from '@angular/core'; 2 | import { 3 | ConnectionPositionPair, 4 | FlexibleConnectedPositionStrategy, 5 | HorizontalConnectionPos, 6 | Overlay, 7 | OverlayConfig, 8 | OverlayRef, 9 | ScrollStrategy, 10 | VerticalConnectionPos 11 | } from '@angular/cdk/overlay'; 12 | import { Directionality, Direction } from '@angular/cdk/bidi'; 13 | import { ESCAPE } from '@angular/cdk/keycodes'; 14 | import { TemplatePortal } from '@angular/cdk/portal'; 15 | import { coerceBooleanProperty } from '@angular/cdk/coercion'; 16 | import { Subscription, Subject } from 'rxjs'; 17 | import { takeUntil, take, filter, tap } from 'rxjs/operators'; 18 | 19 | import { SatPopoverComponent } from './popover.component'; 20 | import { 21 | SatPopoverHorizontalAlign, 22 | SatPopoverVerticalAlign, 23 | SatPopoverScrollStrategy, 24 | SatPopoverOpenOptions 25 | } from './types'; 26 | 27 | /** 28 | * Configuration provided by the popover for the anchoring service 29 | * to build the correct overlay config. 30 | */ 31 | interface PopoverConfig { 32 | horizontalAlign: SatPopoverHorizontalAlign; 33 | verticalAlign: SatPopoverVerticalAlign; 34 | hasBackdrop: boolean; 35 | backdropClass: string; 36 | scrollStrategy: SatPopoverScrollStrategy; 37 | forceAlignment: boolean; 38 | lockAlignment: boolean; 39 | panelClass: string | string[]; 40 | } 41 | 42 | @Injectable() 43 | export class SatPopoverAnchoringService implements OnDestroy { 44 | /** Emits when the popover is opened. */ 45 | popoverOpened = new Subject(); 46 | 47 | /** Emits when the popover is closed. */ 48 | popoverClosed = new Subject(); 49 | 50 | /** Reference to the overlay containing the popover component. */ 51 | _overlayRef: OverlayRef; 52 | 53 | /** Reference to the target popover. */ 54 | private _popover: SatPopoverComponent; 55 | 56 | /** Reference to the view container for the popover template. */ 57 | private _viewContainerRef: ViewContainerRef; 58 | 59 | /** Reference to the anchor element. */ 60 | private _anchor: HTMLElement; 61 | 62 | /** Reference to a template portal where the overlay will be attached. */ 63 | private _portal: TemplatePortal; 64 | 65 | /** Single subscription to notifications service events. */ 66 | private _notificationsSubscription: Subscription; 67 | 68 | /** Single subscription to position changes. */ 69 | private _positionChangeSubscription: Subscription; 70 | 71 | /** Whether the popover is presently open. */ 72 | private _popoverOpen = false; 73 | 74 | /** Emits when the service is destroyed. */ 75 | private _onDestroy = new Subject(); 76 | 77 | constructor( 78 | private _overlay: Overlay, 79 | private _ngZone: NgZone, 80 | @Optional() private _dir: Directionality 81 | ) {} 82 | 83 | ngOnDestroy() { 84 | // Destroy popover before terminating subscriptions so that any resulting 85 | // detachments update 'closed state' 86 | this._destroyPopover(); 87 | 88 | // Terminate subscriptions 89 | if (this._notificationsSubscription) { 90 | this._notificationsSubscription.unsubscribe(); 91 | } 92 | if (this._positionChangeSubscription) { 93 | this._positionChangeSubscription.unsubscribe(); 94 | } 95 | this._onDestroy.next(); 96 | this._onDestroy.complete(); 97 | 98 | this.popoverOpened.complete(); 99 | this.popoverClosed.complete(); 100 | } 101 | 102 | /** Anchor a popover instance to a view and connection element. */ 103 | anchor(popover: SatPopoverComponent, viewContainerRef: ViewContainerRef, anchor: ElementRef | HTMLElement): void { 104 | // If we're just changing the anchor element and the overlayRef already exists, 105 | // simply update the existing _overlayRef's anchor. 106 | if (this._popover === popover && this._viewContainerRef === viewContainerRef && this._overlayRef) { 107 | this._anchor = anchor instanceof ElementRef ? anchor.nativeElement : anchor; 108 | const config = this._overlayRef.getConfig(); 109 | const strategy = config.positionStrategy as FlexibleConnectedPositionStrategy; 110 | strategy.setOrigin(this._anchor); 111 | this._overlayRef.updatePosition(); 112 | return; 113 | } 114 | 115 | // Destroy any previous popovers 116 | this._destroyPopover(); 117 | 118 | // Assign local refs 119 | this._popover = popover; 120 | this._viewContainerRef = viewContainerRef; 121 | this._anchor = anchor instanceof ElementRef ? anchor.nativeElement : anchor; 122 | } 123 | 124 | /** Gets whether the popover is presently open. */ 125 | isPopoverOpen(): boolean { 126 | return this._popoverOpen; 127 | } 128 | 129 | /** Toggles the popover between the open and closed states. */ 130 | togglePopover(): void { 131 | return this._popoverOpen ? this.closePopover() : this.openPopover(); 132 | } 133 | 134 | /** Opens the popover. */ 135 | openPopover(options: SatPopoverOpenOptions = {}): void { 136 | if (!this._popoverOpen) { 137 | this._applyOpenOptions(options); 138 | this._createOverlay(); 139 | this._subscribeToBackdrop(); 140 | this._subscribeToEscape(); 141 | this._subscribeToDetachments(); 142 | this._saveOpenedState(); 143 | } 144 | } 145 | 146 | /** Closes the popover. */ 147 | closePopover(value?: unknown): void { 148 | if (this._overlayRef) { 149 | this._saveClosedState(value); 150 | this._overlayRef.detach(); 151 | } 152 | } 153 | 154 | /** TODO: implement when the overlay's position can be dynamically changed */ 155 | repositionPopover(): void { 156 | this.updatePopoverConfig(); 157 | } 158 | 159 | /** TODO: when the overlay's position can be dynamically changed, do not destroy */ 160 | updatePopoverConfig(): void { 161 | this._destroyPopoverOnceClosed(); 162 | } 163 | 164 | /** Realign the popover to the anchor. */ 165 | realignPopoverToAnchor(): void { 166 | if (this._overlayRef) { 167 | const config = this._overlayRef.getConfig(); 168 | const strategy = config.positionStrategy as FlexibleConnectedPositionStrategy; 169 | strategy.reapplyLastPosition(); 170 | } 171 | } 172 | 173 | /** Get a reference to the anchor element. */ 174 | getAnchorElement(): HTMLElement { 175 | return this._anchor; 176 | } 177 | 178 | /** Apply behavior properties on the popover based on the open options. */ 179 | private _applyOpenOptions(options: SatPopoverOpenOptions): void { 180 | // Only override restoreFocus as `false` if the option is explicitly `false` 181 | const restoreFocus = options.restoreFocus !== false; 182 | this._popover._restoreFocusOverride = restoreFocus; 183 | 184 | // Only override autoFocus as `false` if the option is explicitly `false` 185 | const autoFocus = options.autoFocus !== false; 186 | this._popover._autoFocusOverride = autoFocus; 187 | } 188 | 189 | /** Create an overlay to be attached to the portal. */ 190 | private _createOverlay(): OverlayRef { 191 | // Create overlay if it doesn't yet exist 192 | if (!this._overlayRef) { 193 | this._portal = new TemplatePortal(this._popover._templateRef, this._viewContainerRef); 194 | 195 | const popoverConfig: PopoverConfig = { 196 | horizontalAlign: this._popover.horizontalAlign, 197 | verticalAlign: this._popover.verticalAlign, 198 | hasBackdrop: coerceBooleanProperty(this._popover.hasBackdrop), 199 | backdropClass: this._popover.backdropClass, 200 | scrollStrategy: this._popover.scrollStrategy, 201 | forceAlignment: coerceBooleanProperty(this._popover.forceAlignment), 202 | lockAlignment: coerceBooleanProperty(this._popover.lockAlignment), 203 | panelClass: this._popover.panelClass 204 | }; 205 | 206 | const overlayConfig = this._getOverlayConfig(popoverConfig, this._anchor); 207 | 208 | this._subscribeToPositionChanges(overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy); 209 | 210 | this._overlayRef = this._overlay.create(overlayConfig); 211 | } 212 | 213 | // Actually open the popover 214 | this._overlayRef.attach(this._portal); 215 | return this._overlayRef; 216 | } 217 | 218 | /** Removes the popover from the DOM. Does NOT update open state. */ 219 | private _destroyPopover(): void { 220 | if (this._overlayRef) { 221 | this._overlayRef.dispose(); 222 | this._overlayRef = null; 223 | } 224 | } 225 | 226 | /** 227 | * Destroys the popover immediately if it is closed, or waits until it 228 | * has been closed to destroy it. 229 | */ 230 | private _destroyPopoverOnceClosed(): void { 231 | if (this.isPopoverOpen() && this._overlayRef) { 232 | this._overlayRef 233 | .detachments() 234 | .pipe(take(1), takeUntil(this._onDestroy)) 235 | .subscribe(() => this._destroyPopover()); 236 | } else { 237 | this._destroyPopover(); 238 | } 239 | } 240 | 241 | /** Close popover when backdrop is clicked. */ 242 | private _subscribeToBackdrop(): void { 243 | this._overlayRef 244 | .backdropClick() 245 | .pipe( 246 | tap(() => this._popover.backdropClicked.emit()), 247 | filter(() => this._popover.interactiveClose), 248 | takeUntil(this.popoverClosed), 249 | takeUntil(this._onDestroy) 250 | ) 251 | .subscribe(() => this.closePopover()); 252 | } 253 | 254 | /** Close popover when escape keydown event occurs. */ 255 | private _subscribeToEscape(): void { 256 | this._overlayRef 257 | .keydownEvents() 258 | .pipe( 259 | tap((event) => this._popover.overlayKeydown.emit(event)), 260 | filter((event) => event.keyCode === ESCAPE), 261 | filter(() => this._popover.interactiveClose), 262 | takeUntil(this.popoverClosed), 263 | takeUntil(this._onDestroy) 264 | ) 265 | .subscribe(() => this.closePopover()); 266 | } 267 | 268 | /** Set state back to closed when detached. */ 269 | private _subscribeToDetachments(): void { 270 | this._overlayRef 271 | .detachments() 272 | .pipe(takeUntil(this._onDestroy)) 273 | .subscribe(() => this._saveClosedState()); 274 | } 275 | 276 | /** Save the opened state of the popover and emit. */ 277 | private _saveOpenedState(): void { 278 | if (!this._popoverOpen) { 279 | this._popover._state = 'enter'; 280 | this._popover._open = this._popoverOpen = true; 281 | 282 | this.popoverOpened.next(); 283 | this._popover.opened.emit(); 284 | } 285 | } 286 | 287 | /** Save the closed state of the popover and emit. */ 288 | private _saveClosedState(value?: unknown): void { 289 | if (this._popoverOpen) { 290 | this._popover._open = this._popoverOpen = false; 291 | 292 | this._popover._startExitAnimation(); 293 | this.popoverClosed.next(value); 294 | this._popover.closed.emit(value); 295 | } 296 | } 297 | 298 | /** Gets the text direction of the containing app. */ 299 | private _getDirection(): Direction { 300 | return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr'; 301 | } 302 | 303 | /** Create and return a config for creating the overlay. */ 304 | private _getOverlayConfig(config: PopoverConfig, anchor: HTMLElement): OverlayConfig { 305 | return new OverlayConfig({ 306 | positionStrategy: this._getPositionStrategy( 307 | config.horizontalAlign, 308 | config.verticalAlign, 309 | config.forceAlignment, 310 | config.lockAlignment, 311 | anchor 312 | ), 313 | hasBackdrop: config.hasBackdrop, 314 | backdropClass: config.backdropClass || 'cdk-overlay-transparent-backdrop', 315 | scrollStrategy: this._getScrollStrategyInstance(config.scrollStrategy), 316 | direction: this._getDirection(), 317 | panelClass: config.panelClass 318 | }); 319 | } 320 | 321 | /** 322 | * Listen to changes in the position of the overlay and set the correct alignment classes, 323 | * ensuring that the animation origin is correct, even with a fallback position. 324 | */ 325 | private _subscribeToPositionChanges(position: FlexibleConnectedPositionStrategy): void { 326 | if (this._positionChangeSubscription) { 327 | this._positionChangeSubscription.unsubscribe(); 328 | } 329 | 330 | this._positionChangeSubscription = position.positionChanges.pipe(takeUntil(this._onDestroy)).subscribe((change) => { 331 | // Position changes may occur outside the Angular zone 332 | this._ngZone.run(() => { 333 | this._popover._setAlignmentClasses( 334 | getHorizontalPopoverAlignment(change.connectionPair.overlayX), 335 | getVerticalPopoverAlignment(change.connectionPair.overlayY) 336 | ); 337 | }); 338 | }); 339 | } 340 | 341 | /** Map a scroll strategy string type to an instance of a scroll strategy. */ 342 | private _getScrollStrategyInstance(strategy: SatPopoverScrollStrategy): ScrollStrategy { 343 | switch (strategy) { 344 | case 'block': 345 | return this._overlay.scrollStrategies.block(); 346 | case 'reposition': 347 | return this._overlay.scrollStrategies.reposition(); 348 | case 'close': 349 | return this._overlay.scrollStrategies.close(); 350 | case 'noop': 351 | default: 352 | return this._overlay.scrollStrategies.noop(); 353 | } 354 | } 355 | 356 | /** Create and return a position strategy based on config provided to the component instance. */ 357 | private _getPositionStrategy( 358 | horizontalTarget: SatPopoverHorizontalAlign, 359 | verticalTarget: SatPopoverVerticalAlign, 360 | forceAlignment: boolean, 361 | lockAlignment: boolean, 362 | anchor: HTMLElement 363 | ): FlexibleConnectedPositionStrategy { 364 | // Attach the overlay at the preferred position 365 | const targetPosition = getPosition(horizontalTarget, verticalTarget); 366 | const positions = [targetPosition]; 367 | 368 | const strategy = this._overlay 369 | .position() 370 | .flexibleConnectedTo(anchor) 371 | .withFlexibleDimensions(false) 372 | .withPush(false) 373 | .withViewportMargin(0) 374 | .withLockedPosition(lockAlignment); 375 | 376 | // Unless the alignment is forced, add fallbacks based on the preferred positions 377 | if (!forceAlignment) { 378 | const fallbacks = this._getFallbacks(horizontalTarget, verticalTarget); 379 | positions.push(...fallbacks); 380 | } 381 | 382 | return strategy.withPositions(positions); 383 | } 384 | 385 | /** Get fallback positions based around target alignments. */ 386 | private _getFallbacks( 387 | hTarget: SatPopoverHorizontalAlign, 388 | vTarget: SatPopoverVerticalAlign 389 | ): ConnectionPositionPair[] { 390 | // Determine if the target alignments overlap the anchor 391 | const horizontalOverlapAllowed = hTarget !== 'before' && hTarget !== 'after'; 392 | const verticalOverlapAllowed = vTarget !== 'above' && vTarget !== 'below'; 393 | 394 | // If a target alignment doesn't cover the anchor, don't let any of the fallback alignments 395 | // cover the anchor 396 | const possibleHorizontalAlignments: SatPopoverHorizontalAlign[] = horizontalOverlapAllowed 397 | ? ['before', 'start', 'center', 'end', 'after'] 398 | : ['before', 'after']; 399 | const possibleVerticalAlignments: SatPopoverVerticalAlign[] = verticalOverlapAllowed 400 | ? ['above', 'start', 'center', 'end', 'below'] 401 | : ['above', 'below']; 402 | 403 | // Create fallbacks for each allowed prioritized fallback alignment combo 404 | const fallbacks: ConnectionPositionPair[] = []; 405 | prioritizeAroundTarget(hTarget, possibleHorizontalAlignments).forEach((h) => { 406 | prioritizeAroundTarget(vTarget, possibleVerticalAlignments).forEach((v) => { 407 | fallbacks.push(getPosition(h, v)); 408 | }); 409 | }); 410 | 411 | // Remove the first item since it will be the target alignment and isn't considered a fallback 412 | return fallbacks.slice(1, fallbacks.length); 413 | } 414 | } 415 | 416 | /** Helper function to get a cdk position pair from SatPopover alignments. */ 417 | function getPosition(h: SatPopoverHorizontalAlign, v: SatPopoverVerticalAlign): ConnectionPositionPair { 418 | const { originX, overlayX } = getHorizontalConnectionPosPair(h); 419 | const { originY, overlayY } = getVerticalConnectionPosPair(v); 420 | return new ConnectionPositionPair({ originX, originY }, { overlayX, overlayY }); 421 | } 422 | 423 | /** Helper function to convert an overlay connection position to equivalent popover alignment. */ 424 | function getHorizontalPopoverAlignment(h: HorizontalConnectionPos): SatPopoverHorizontalAlign { 425 | if (h === 'start') { 426 | return 'after'; 427 | } 428 | 429 | if (h === 'end') { 430 | return 'before'; 431 | } 432 | 433 | return 'center'; 434 | } 435 | 436 | /** Helper function to convert an overlay connection position to equivalent popover alignment. */ 437 | function getVerticalPopoverAlignment(v: VerticalConnectionPos): SatPopoverVerticalAlign { 438 | if (v === 'top') { 439 | return 'below'; 440 | } 441 | 442 | if (v === 'bottom') { 443 | return 'above'; 444 | } 445 | 446 | return 'center'; 447 | } 448 | 449 | /** Helper function to convert alignment to origin/overlay position pair. */ 450 | function getHorizontalConnectionPosPair(h: SatPopoverHorizontalAlign): { 451 | originX: HorizontalConnectionPos; 452 | overlayX: HorizontalConnectionPos; 453 | } { 454 | switch (h) { 455 | case 'before': 456 | return { originX: 'start', overlayX: 'end' }; 457 | case 'start': 458 | return { originX: 'start', overlayX: 'start' }; 459 | case 'end': 460 | return { originX: 'end', overlayX: 'end' }; 461 | case 'after': 462 | return { originX: 'end', overlayX: 'start' }; 463 | default: 464 | return { originX: 'center', overlayX: 'center' }; 465 | } 466 | } 467 | 468 | /** Helper function to convert alignment to origin/overlay position pair. */ 469 | function getVerticalConnectionPosPair(v: SatPopoverVerticalAlign): { 470 | originY: VerticalConnectionPos; 471 | overlayY: VerticalConnectionPos; 472 | } { 473 | switch (v) { 474 | case 'above': 475 | return { originY: 'top', overlayY: 'bottom' }; 476 | case 'start': 477 | return { originY: 'top', overlayY: 'top' }; 478 | case 'end': 479 | return { originY: 'bottom', overlayY: 'bottom' }; 480 | case 'below': 481 | return { originY: 'bottom', overlayY: 'top' }; 482 | default: 483 | return { originY: 'center', overlayY: 'center' }; 484 | } 485 | } 486 | 487 | /** 488 | * Helper function that takes an ordered array options and returns a reorderded 489 | * array around the target item. e.g.: 490 | * 491 | * target: 3; options: [1, 2, 3, 4, 5, 6, 7]; 492 | * 493 | * return: [3, 4, 2, 5, 1, 6, 7] 494 | */ 495 | function prioritizeAroundTarget(target: T, options: T[]): T[] { 496 | const targetIndex = options.indexOf(target); 497 | 498 | // Set the first item to be the target 499 | const reordered = [target]; 500 | 501 | // Make left and right stacks where the highest priority item is last 502 | const left = options.slice(0, targetIndex); 503 | const right = options.slice(targetIndex + 1, options.length).reverse(); 504 | 505 | // Alternate between stacks until one is empty 506 | while (left.length && right.length) { 507 | reordered.push(right.pop()); 508 | reordered.push(left.pop()); 509 | } 510 | 511 | // Flush out right side 512 | while (right.length) { 513 | reordered.push(right.pop()); 514 | } 515 | 516 | // Flush out left side 517 | while (left.length) { 518 | reordered.push(left.pop()); 519 | } 520 | 521 | return reordered; 522 | } 523 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # CHANGELOG 4 | 5 | ## 15.0.0 stuff-gruff 6 | 7 | ### Breaking Changes 8 | 9 | - Peer dependencies of @angular/{\*\*} are now set to ~20 or higher. 10 | - Some of the highlights: 11 | - Stabilizing APIs such as effect, linkedSignal, toSignal, incremental hydration, route-level render mode config and promoting zoneless to developer preview 12 | - Improved debugging with Angular DevTools and partnering with Chrome for custom Angular reporting directly in Chrome DevTools 13 | - Polishing developer experience with style guide updates, type checking and language service support for host bindings, support for untagged template literal expressions in templates, template hot module replacement by default, and more. 14 | - Advancements in GenAI development with llms.txt and angular.dev guides and videos for building Generative AI applications 15 | - Launching a request for comments for an official mascot for Angular 16 | 17 | ### Notes 18 | 19 | Recommended reading: 20 | 21 | - [Announcing Angular v20](https://blog.angular.dev/announcing-angular-v20-b5c9c06cf301) 22 | - [Angular 19-20 Update Guide](https://angular.dev/update-guide?v=19.0-20.0&l=2) 23 | 24 | ## 14.5.0 doubt-drought 25 | 26 | ### Breaking Changes 27 | 28 | - None 29 | 30 | ### Notes 31 | 32 | - Updated dependencies to resolve moderate build chain vulnerabilities. 33 | 34 | ## 14.4.0 car-czar 35 | 36 | ### Breaking Changes 37 | 38 | - None 39 | 40 | ### Notes 41 | 42 | - Updated dependencies to resolve moderate build chain vulnerabilities. 43 | - node to 20.17.0 44 | - npm to 11.3.0 45 | 46 | ## 14.3.0 pale-pale 47 | 48 | ### Breaking Changes 49 | 50 | - None 51 | 52 | ### Notes 53 | 54 | - Updated dependencies to resolve moderate build chain vulnerabilities. 55 | 56 | ## 14.2.0 overdue-overdo 57 | 58 | ### Breaking Changes 59 | 60 | - None 61 | 62 | ### Notes 63 | 64 | - Updated dependencies to resolve moderate build chain vulnerabilities. 65 | 66 | ## 14.1.0 need-knead 67 | 68 | ### Breaking Changes 69 | 70 | - None 71 | 72 | ## 14.0.0 hoarse-horse 73 | 74 | ### Breaking Changes 75 | 76 | - Peer dependencies of @angular/{\*\*} are now set to ~19 or higher. 77 | - Angular 19 has made `standalone` components the default, which significantly affects how SatPopover is loaded. 78 | 79 | ### Notes 80 | 81 | Recommended reading: 82 | 83 | - [Angular 19 Introduction](https://blog.angular.dev/meet-angular-v19-7b29dfd05b84) 84 | - [Angular 18-19 Update Guide](https://angular.dev/update-guide?v=18.0-19.0&l=1) 85 | 86 | **_Bootstrapping has changed!_** 87 | 88 | If you want the popover animations to work, you must use `provideAnimations` in your `AppConfig`; 89 | or, if you prefer to not have animations, you can use `provideNoopAnimations` in your `AppConfig`. 90 | 91 | ```ts 92 | import { provideAnimations, provideNoopAnimations } from '@angular/platform-browser/animations'; 93 | 94 | bootstrapApplication(AppComponent, { 95 | // AppConfig 96 | providers: [ 97 | provideAnimations() 98 | /* provideNoopAnimations() /* Use if you want to disable animations */ 99 | ] 100 | }); 101 | ``` 102 | 103 | Alteranatively, if you still use `bootstrapModule`, you can continue to import `BrowserAnimationsModule` in this manner. 104 | 105 | ```ts 106 | import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations'; 107 | @NgModule({ 108 | ... 109 | imports: [ 110 | BrowserAnimationsModule, // Use if you want to enable animations 111 | /* NoopAnimationsModule /* Use if you want to disable animations */ 112 | ], 113 | ... 114 | }) 115 | export class AppModule { } 116 | ``` 117 | 118 | **Can I still use modules to import SatPopover?** 119 | 120 | Yes. SatPopoverModule can still be imported, although you may still have to import `SatPopoverComponent`, 121 | `SatPopoverAnchorDirective`, or `SatPopoverHoverDirective` as necessary. 122 | 123 | ```ts 124 | // my-component.module.ts 125 | import { SatPopoverModule } from '@ncstate/sat-popover'; 126 | 127 | @NgModule({ 128 | ... 129 | imports: [ 130 | SatPopoverModule, 131 | ], 132 | exports: [ 133 | MyComponent 134 | ], 135 | ... 136 | }) 137 | export class MyComponentModule { } 138 | 139 | // my-component.component.ts 140 | import { Component, ViewChild } from '@angular/core'; 141 | import { SatPopoverAnchorDirective, SatPopoverComponent } from '@ncstate/sat-popover'; 142 | 143 | @Component({ 144 | standalone: false, 145 | selector: 'my-component', 146 | template: ' Hello!' 147 | }) 148 | export class MyComponent {} 149 | ``` 150 | 151 | ## 13.1.0 their-there 152 | 153 | ### Breaking Changes 154 | 155 | - None 156 | 157 | ### Notes 158 | 159 | - Peer dependencies of @angular/{\*\*} are now set to ~18.2 or higher 160 | - Addresses some vulnerabilities in dependencies used by Angular's build system. 161 | 162 | ## 13.0.2 submarine-plumbing 163 | 164 | ### Breaking Changes 165 | 166 | - None 167 | 168 | ### Notes 169 | 170 | - Correcting the npm account for the latest release 171 | 172 | ## 13.0.1 plain-plane 173 | 174 | ### Breaking Changes 175 | 176 | - None 177 | 178 | ### Notes 179 | 180 | - Peer dependencies of @angular/{\*\*} are now set to ~18.0 181 | 182 | ## 12.0.1 mellow-hello 183 | 184 | ### Breaking Changes 185 | 186 | - None 187 | 188 | ### Notes 189 | 190 | - @angular/cdk and @angular/material are updated from 17.3.1 to 17.3.2 191 | 192 | ## 12.0.0 peirz-peers 193 | 194 | ### Breaking Changes 195 | 196 | - Peer dependencies of @angular/{\*\*} are now set to ~17.3 197 | - Peer dependencies are re-established in the published `package.json` 198 | - Constraints are adjusted from patch versions to major versions. 199 | 200 | ## 11.0.0 jimny-cricket 201 | 202 | ### Breaking Changes 203 | 204 | - Peer dependencies of @angular/{\*\*} are now set to ^17.1.2 205 | - Any reference to `SatPopover` must be changed to `SatPopoverComponent`. 206 | This complies with the [Angular style guide](https://angular.io/guide/styleguide#style-02-03). 207 | - tslint is removed in favor of eslint. 208 | - ivy build options are removed and no longer supported, as they are the default. 209 | 210 | ## 10.3.2 thrice-asnice 211 | 212 | - Updated other packages 213 | 214 | ## 10.3.1 bibbidy-bobbidy 215 | 216 | ### Breaking Changes 217 | 218 | - Peer dependencies of @angular/{\*\*} are now set to ^16.2.6 219 | 220 | ## 10.3.0 wobbly-bobbly 221 | 222 | ### Breaking Changes 223 | 224 | - Peer dependencies of @angular/{\*\*} are now set to ^16.2.4 225 | - `ngcc` removed from `postinstall` hook, as it is no longer required. 226 | 227 | ## 10.2.2 freaky-deaky 228 | 229 | Tagging got screwed up. 10.2.2 is good. 230 | 231 | ## 10.2.1 silly-vanilly 232 | 233 | ### Breaking Changes 234 | 235 | - Peer dependencies of @angular/{\*\*} are now set to ^15.1.1. 236 | 237 | ## 10.1.0 rhyme-thyme 238 | 239 | ### Breaking Changes 240 | 241 | - Peer dependencies of @angular/{\*\*} are now set to ^15.0.4. 242 | 243 | ## 10.0.0 holder-boulder 244 | 245 | ### Breaking Changes 246 | 247 | - Peer dependencies of @angular/{\*\*} are now set to ^14.0.4. 248 | 249 | ## 9.0.1 webpack-schmwebpack 250 | 251 | ### Fixes 252 | 253 | - Webpack issues introduced in v9.0.0 254 | 255 | ## 9.0.0 funky-monkey 256 | 257 | ### Breaking Changes (aka Features) 258 | 259 | - Peer dependencies of @angular/{\*\*} are now set to ^13.3.6. 260 | 261 | ## 8.0.1 stinky-skink 262 | 263 | ### Breaking Changes (aka Features) 264 | 265 | - Specifically excluding suppport for IE 9-11 266 | 267 | ### Fixes 268 | 269 | - Transition firing for afterClose now fires after close ;) 270 | 271 | ## 8.0.0 fowl-owl 272 | 273 | ### Breaking Changes (aka Features) 274 | 275 | - Peer dependencies of `@angular/{core,common,cdk}` are now set to `^12.1.3`. 276 | 277 | ### Fixes 278 | 279 | - Transitions not firing after first time bug was killed 280 | 281 | ## 7.1.0 parakeet-bigfeet 282 | 283 | ### Features 284 | 285 | - Added panelClass option 286 | - Added injection token for default transition 287 | - Added ability to customizable scale values for animation 288 | - General cleanup, and added git hook/package script for linting 289 | 290 | ### Fixes 291 | 292 | - Demo build wasn't building 293 | 294 | ## 7.0.0 matzah-pasta 295 | 296 | ### Breaking Changes (aka Features) 297 | 298 | - Peer dependencies of `@angular/{core,common,cdk}` are now set to `^11.0.0`. 299 | 300 | ## 6.0.0 whatza-pizza 301 | 302 | ### Breaking Changes (aka Features) 303 | 304 | - Peer dependencies of `@angular/{core,common,cdk}` are now set to `^10.0.*`. 305 | 306 | ## 5.0.0 schmock-frock 307 | 308 | ### Breaking Changes (aka Features) 309 | 310 | - Peer dependencies of `@angular/{core,common,cdk}` are now set to `^9.0.4`. 311 | 312 | ## 4.0.0 quarrel-squirrel 313 | 314 | ### Breaking Changes (aka Features) 315 | 316 | - Peer dependencies of `@angular/{core,common,cdk}` are now set to `^8.0.0`. 317 | - Dynamic Anchors are now available. See README for more information on this breaking change. 318 | - No significant changes from v3.3.0, just bumping version via `semver` protocol. 319 | 320 | ## 3.3.0 vigorous-tigress (obsoleted with v4.0.0) 321 | 322 | ### Breaking Changes (aka Features) 323 | 324 | - Peer dependencies of `@angular/{core,common,cdk}` are now set to `^8.0.0`. 325 | - Dynamic Anchors are now available. See README for more information on this breaking change. 326 | 327 | ## 3.2.0 yttrium-atrium 328 | 329 | ### Features 330 | 331 | - You can now use the [`SatPopoverHoverDirective`](https://github.com/ncstate-sat/popover/blob/master/README.md#hover) 332 | which provides built in hover and delay mechanics (thank you @Abrissirba!). Add the directive 333 | to any child element of the anchor and supply an optional delay. 334 | 335 | ## 3.1.0 cactus-malpractice 336 | 337 | ### Features 338 | 339 | - Focus restoration can now be disabled via the [`restoreFocus`](https://github.com/ncstate-sat/popover/blob/master/README.md#focus-behavior) 340 | property. 341 | - The popover/anchor now have `realign`/`realignPopover` methods to realign the popover to 342 | the anchor in case the anchor's size or position changes. 343 | - The anchor now has a `getElement` method for getting the anchor's `elementRef`. 344 | - The `open` and `openPopover` methods now support an optional `SatPopoverOpenOptions` object where 345 | `autoFocus` and `restoreFocus` options can be set while opening the popover. Note that these 346 | options do no take precendence over the component inputs. For example, if `restoreFocus` 347 | is set to `false` either in the open options or via the component input, focus will not be restored. 348 | 349 | ## 3.0.0 reliance-compliance 350 | 351 | ### Breaking Changes 352 | 353 | Peer dependencies of `@angular/{core,common,cdk}` are now set to `^7.0.0`. 354 | 355 | ### Other 356 | 357 | - Build library before tagging release 358 | 359 | ## 2.1.1 daybreak-cheesecake 360 | 361 | ### Other 362 | 363 | - Copy README to published package so it shows up on npm 364 | - Add current version number to the demo/development app 365 | 366 | ## 2.1.0 refactor-benefactor 367 | 368 | ### Features 369 | 370 | - `SatPopoverAnchoringService` is now exposed as part of the public api. This allows you to 371 | build your own directives and services to anchor popovers to any element. An easier-to-use 372 | service is forthcoming. 373 | 374 | ### Other 375 | 376 | - Internally use the `@angular/cli` for the build/test toolchain. This thankfully gets rid of 377 | a bunch of the hokey build scripts and will ease future development. There should be no 378 | change from the end-user's perspective. 379 | 380 | ## 2.0.0 linchpin-luncheon 381 | 382 | ### Breaking Changes 383 | 384 | Peer dependencies of `@angular/{core,common,cdk}` are now set to `^6.0.0`. 385 | 386 | ### Fixes 387 | 388 | - RxJS and CDK rollup globals have been fixed for ng 6 update (thank you @aitboudad!) 389 | 390 | ### Other 391 | 392 | - Internally use the FlexibleConnectedPositionStrategy for connected positioning. This uses the 393 | same logic as the ConnectedPositionStrategy did previously, so it should not break or change 394 | any behavior. 395 | 396 | ## 2.0.0-beta.0 unanimous-uniform 397 | 398 | ### Breaking Changes 399 | 400 | Peer dependencies of `@angular/{core,common,cdk}` are now set to `>=6.0.0-rc.0 <7.0.0`. These will be 401 | updated to `^6.0.0` in the popover's `2.0.0` release. 402 | 403 | ## 1.0.0 popover-panda 404 | 405 | The API seems pretty stable, so 1.0.0 it is! 406 | 407 | ### Features 408 | 409 | - By default, the popover will spin through a couple fallback alignments when the specified one 410 | does not fit within the viewport. This can be troublesome if your popover requires some cardinal 411 | relationship with your anchor (e.g. tooltip caret). You can now use `forceAlignment` to ensure 412 | the alignment you've chosen is the one used. 413 | - By default, when the user scrolls (or changes the viewport size), the popover will continue 414 | to use the fallbacks to remain within the viewport. This is potentially distracting, so you can 415 | now use `lockAlignment` to ensure the popover maintains the same alignment as long as it is open. 416 | It will be recalculated the next time the popover is opened. 417 | 418 | ## 1.0.0-beta.5 cryptographic-cereal 419 | 420 | ### Features 421 | 422 | - Autofocus behavior can be disabled via [`autoFocus`](https://github.com/ncstate-sat/popover/blob/master/README.md#focus-behavior). 423 | - Interactive closing actions (i.e. backdrop clicks and escape key) can now be disabled via [`interactiveClose`](https://github.com/ncstate-sat/popover/blob/master/README.md#interactive-closing). You can still use the `(backdropClicked)` and `(overlayKeydown)` outputs to catch those events. 424 | 425 | ### Fixes 426 | 427 | - Popover directionality now works with `dir` set on elements other than ``. 428 | 429 | ### Other 430 | 431 | - In preparation of support for popovers being anchored and opened via a service, the overlay 432 | logic has been refactored out of the anchor directive and into another service. This should have 433 | no impact on the usage of the popover. 434 | 435 | ## 1.0.0-beta.4 rezoned-rhombus 436 | 437 | ### Breaking Changes 438 | 439 | Peer dependency of `@angular/cdk` is now set to `^5.0.0`. 440 | 441 | ## 1.0.0-beta.3 karmic-kismet 442 | 443 | ### Breaking Changes 444 | 445 | Peer dependency of `@angular/cdk` is now set to `^5.0.0-rc.1`. 446 | 447 | ### Features 448 | 449 | - The popover now has `backdropClicked` and `overlayKeydown` outputs. 450 | 451 | ### Fixes 452 | 453 | - The last release had a regression where the transform origin wouldn't update when the position 454 | changed. This would cause the popover to sometimes animate to or from the wrong direction. 455 | 456 | ### Other 457 | 458 | - Check the new [speed dial demo](https://ncstate-sat.github.io/popover) 459 | - Using the test runner is way less obnoxious 460 | - Build script is somewhat simplified by not required extra tsconfigs 461 | - The popover is now closed with the overlay keydown stream instead of a keydown handler in the 462 | template. This means that even popovers without focusable elements can be closed with 463 | esc. Try it on a tooltip. 464 | - Rudimentary Travis tests are in place with some sweet new badges in the README 465 | 466 | ## 1.0.0-beta.2 deserting-descartes 467 | 468 | ### Breaking Changes 469 | 470 | The biggest change this release is how positioning the popover works. You can now align 471 | popovers at the `start` or `end` of the anchor, on either or both axes. This removes the need to 472 | have an `overlapAnchor` option. Further, to better describe the intention of the positioning 473 | parameters, `xPosition` has been renamed to `horizontalAlign` and `yPosition` has been renamed to 474 | `verticalAlign`. 475 | 476 | We hope these two changes will make it easier to depict a mental model of the expected behavior. 477 | It also gives you 8 more possible positions! 478 | 479 | This table should give you an idea of how to migrate: 480 | 481 | | Previously | Currently | 482 | | ----------------------------------------- | ------------------------- | 483 | | `xPosition="before" overlapAnchor="true"` | `horizontalAlign="end"` | 484 | | `xPosition="after" overlapAnchor="false"` | `horizontalAlign="after"` | 485 | | `yPosition="below" overlapAnchor="true"` | `verticalAlign="start"` | 486 | | `yPosition="above" overlapAnchor="false"` | `verticalAlign="above"` | 487 | 488 | For convenience, aliases have also been provided 489 | 490 | | Input | Alias | 491 | | ----------------- | -------- | 492 | | `horizontalAlign` | `xAlign` | 493 | | `verticalAlign` | `yAlign` | 494 | 495 | The following have also been renamed: 496 | 497 | - `SatPopoverPositionX` -> `SatPopoverHorizontalAlign` 498 | - `SatPopoverPositionY` -> `SatPopoverVerticalAlign` 499 | 500 | ### Features 501 | 502 | - Add `start` and `end` options to `horizontalAlign` and `verticalAlign`. 503 | - Use better fallback strategy that originates from target alignment 504 | - The popover now has `afterOpen` and `afterClose` outputs that emit when the animation is complete 505 | - The popover now has a `'close'` scroll strategy. It will close itself whenever the parent 506 | container is scrolled. 507 | 508 | ### Fixes 509 | 510 | - Switch to rxjs lettable operators to avoid polluting user's global Rx prototype 511 | - Allow user to declare popover eariler in a template than the anchor 512 | 513 | ### Other 514 | 515 | - Fix typo in readme 516 | - Publish demo app at https://ncstate-sat.github.io/popover/ 517 | - Add stacblitz starter to readme and issue template 518 | - Rename 'position' to 'align' and 'x/y' to 'horizontal/vertical' 519 | - Support cdk @ 5.0.0-rc0 and Angular 5 520 | 521 | ## 1.0.0-beta.1 flopover-facsimile 522 | 523 | ### Breaking Changes 524 | 525 | The npm package name has changed from `@sat/popover` to `@ncstate/sat-popover`. All class names 526 | and directive selectors are the same. 527 | 528 | ``` 529 | npm uninstall @sat/popover 530 | npm install --save @ncstate/sat-popover 531 | ``` 532 | 533 | ```ts 534 | import { SatPopoverModule } from '@ncstate/sat-popover'; 535 | ``` 536 | 537 | ### Features 538 | 539 | - By default, the opening and closing animations of a popover are quick with a simple easing curve. 540 | You can now modify these animation curves using `openTransition` and `closeTransition`. 541 | - By default, when a popover is open and the user scrolls the container, the popover will reposition 542 | itself to stay attached to its anchor. You can now adjust this behavior with `scrollStrategy`. 543 | - RTL support. The popover will now position and animate itself in accordance with the document's 544 | body direction. 545 | 546 | ### Fixes 547 | 548 | - Pressing esc while focused inside a popover will now properly close the popover. 549 | This was a regression introduced in the last release. 550 | - Changing the position properties of a popover will now apply even if the popover has been opened 551 | before. 552 | - Recreation of the popover waits until it is closed so that the popover isn't disposed while open. 553 | 554 | ### Other 555 | 556 | - An error will be thrown if you try to call the open/close/toggle methods on a popover with 557 | no corresponding anchor. 558 | - An error will be thrown if you try to pass an invalid `xPosition` or `yPosition` 559 | - Refactor of the demo-app to better encapsulate each demo. 560 | - Updated import statement in README (thanks to @julianobrasil) 561 | - Added note to README about `cdkScrollable` 562 | 563 | ## 1.0.0-beta.0 binaural-bongo 564 | 565 | ### Breaking Changes 566 | 567 | - Anchors not longer default to toggling the popover on click. That means you are required to 568 | manually open and close the popover as needed. This is done to prevent prescription of behavior 569 | and to avoid potentially growing the number of "disable apis". 570 | - That means `satDisableClick` has been removed. 571 | - Backdrops are no longer included by default. For the same reason as click behavior, they are now 572 | opt-in. Use `hasBackdrop` on a popover to specify that a backdrop should appear behind it when open. 573 | - That means `disableBackdrop` has been removed. 574 | - `popoverOpen()` on the anchor has been renamed to `isPopoverOpen()` 575 | 576 | ### Features 577 | 578 | - `SatPopover` now has an `isOpen()` method. 579 | 580 | ### Fixes 581 | 582 | - `opened` output of `SatPopover` works now. 583 | 584 | ### Other 585 | 586 | - Tests have been added for the api facing portions. For now, positioning and fallback behavior 587 | still relies on proper testing in the CDK. 588 | 589 | ## 1.0.0-alpha.3 590 | 591 | ### Features 592 | 593 | - Allow popovers to be opened, closed, or toggled from the component itself, rather than just 594 | the anchor. 595 | - Backdrops can be disabled with the `disableBackdrop` property on the `sat-popover`. 596 | - Backdrops can be customized using the `backdropClass` property on the `sat-popover`. 597 | 598 | ### Other 599 | 600 | - Updates peer @angular/cdk dependency to 2.0.0-beta.12 601 | 602 | ## 1.0.0-alpha.2 603 | 604 | ### Fixes 605 | 606 | - Inlines resources in the metadata files to enable AOT builds 607 | - Adds more properties to the dist package.json for better display on npm 608 | 609 | ## 1.0.0-alpha.1 610 | 611 | ### Fixes 612 | 613 | - Ships cdk/overlay component css - needed to properly position the popover 614 | - Cleans up the README and moves a bunch of TODOs to Github's issue tracker 615 | 616 | -------------------------------------------------------------------------------- /src/lib/popover/popover.spec.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef, Component, ViewChild, ViewContainerRef, importProvidersFrom } from '@angular/core'; 2 | import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; 3 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { 5 | BlockScrollStrategy, 6 | FlexibleConnectedPositionStrategy, 7 | OverlayConfig, 8 | OverlayContainer, 9 | RepositionScrollStrategy, 10 | ScrollStrategy 11 | } from '@angular/cdk/overlay'; 12 | import { ESCAPE, A } from '@angular/cdk/keycodes'; 13 | 14 | import { SatPopoverModule } from './popover.module'; 15 | import { SatPopoverComponent, SatPopoverAnchorDirective } from './popover.component'; 16 | import { SatPopoverAnchoringService } from './popover-anchoring.service'; 17 | import { SatPopoverHoverDirective } from './popover-hover.directive'; 18 | import { 19 | getUnanchoredPopoverError, 20 | getInvalidHorizontalAlignError, 21 | getInvalidVerticalAlignError, 22 | getInvalidScrollStrategyError, 23 | getInvalidPopoverAnchorError, 24 | getInvalidSatPopoverAnchorError 25 | } from './popover.errors'; 26 | import { DEFAULT_TRANSITION } from './tokens'; 27 | 28 | describe('SatPopover', () => { 29 | describe('passing an anchor', () => { 30 | beforeEach(() => { 31 | TestBed.configureTestingModule({ 32 | imports: [ 33 | InvalidPopoverTestComponent, 34 | SimpleDirectiveAnchorPopoverTestComponent, 35 | SimpleHTMLAnchorPopoverTestComponent, 36 | AnchorlessPopoverTestComponent, 37 | InvalidAnchorTestComponent 38 | ], 39 | providers: [importProvidersFrom(SatPopoverModule)] 40 | }); 41 | }); 42 | 43 | it('should throw an error if an invalid object is provided', () => { 44 | const fixture = TestBed.createComponent(InvalidPopoverTestComponent); 45 | 46 | expect(() => { 47 | fixture.detectChanges(); 48 | }).toThrow(getInvalidPopoverAnchorError()); 49 | }); 50 | 51 | it('should not throw an error if a valid "setPopoverAnchor" anchor is provided', () => { 52 | const fixture = TestBed.createComponent(SimpleDirectiveAnchorPopoverTestComponent); 53 | 54 | expect(() => { 55 | fixture.detectChanges(); 56 | }).not.toThrowError(); 57 | }); 58 | 59 | it('should not throw an error if a valid ElementRef anchor is provided', () => { 60 | const fixture = TestBed.createComponent(SimpleHTMLAnchorPopoverTestComponent); 61 | 62 | expect(() => { 63 | fixture.detectChanges(); 64 | }).not.toThrowError(); 65 | }); 66 | 67 | it('should update the anchor if a valid new anchor is provided', () => { 68 | const fixture = TestBed.createComponent(SimpleDirectiveAnchorPopoverTestComponent); 69 | 70 | fixture.detectChanges(); 71 | 72 | const comp = fixture.componentInstance as SimpleDirectiveAnchorPopoverTestComponent; 73 | 74 | expect(comp.popover.anchor).toBe(comp.anchor); 75 | expect(comp.popover._anchoringService.getAnchorElement()).toBe(comp.anchor.elementRef.nativeElement); 76 | 77 | expect(() => { 78 | comp.popover.anchor = comp.alternateAnchorElement; 79 | }).not.toThrowError(); 80 | 81 | expect(comp.popover.anchor).toBe(comp.alternateAnchorElement); 82 | expect(comp.popover._anchoringService.getAnchorElement()).toBe(comp.alternateAnchorElement.nativeElement); 83 | }); 84 | 85 | it('should throw an error if open is called on a popover with no anchor', () => { 86 | const fixture = TestBed.createComponent(AnchorlessPopoverTestComponent); 87 | 88 | // should not throw when just initializing 89 | expect(() => { 90 | fixture.detectChanges(); 91 | }).not.toThrowError(); 92 | 93 | // should throw if it is opening 94 | expect(() => { 95 | fixture.componentInstance.popover.open(); 96 | }).toThrow(getUnanchoredPopoverError()); 97 | }); 98 | 99 | it('should throw an error if an anchor is not associated with a popover', () => { 100 | const fixture = TestBed.createComponent(InvalidAnchorTestComponent); 101 | 102 | expect(() => { 103 | fixture.detectChanges(); 104 | }).toThrow(getInvalidSatPopoverAnchorError()); 105 | }); 106 | }); 107 | 108 | describe('opening and closing behavior', () => { 109 | let fixture: ComponentFixture; 110 | let comp: SimpleDirectiveAnchorPopoverTestComponent; 111 | let overlayContainerElement: HTMLElement; 112 | 113 | beforeEach(() => { 114 | TestBed.configureTestingModule({ 115 | imports: [SimpleDirectiveAnchorPopoverTestComponent, NoopAnimationsModule], 116 | providers: [ 117 | importProvidersFrom(SatPopoverModule), 118 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 119 | ] 120 | }); 121 | 122 | fixture = TestBed.createComponent(SimpleDirectiveAnchorPopoverTestComponent); 123 | comp = fixture.componentInstance; 124 | 125 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 126 | }); 127 | 128 | afterEach(() => { 129 | document.body.removeChild(overlayContainerElement); 130 | }); 131 | 132 | it('should open with open()', () => { 133 | fixture.detectChanges(); 134 | expect(overlayContainerElement.textContent).toBe('', 'Initially closed'); 135 | comp.popover.open(); 136 | expect(overlayContainerElement.textContent).toContain('Popover', 'Subsequently open'); 137 | }); 138 | 139 | it('should close with close()', fakeAsync(() => { 140 | fixture.detectChanges(); 141 | comp.popover.open(); 142 | expect(overlayContainerElement.textContent).toContain('Popover', 'Initially open'); 143 | 144 | comp.popover.close(); 145 | fixture.detectChanges(); 146 | tick(); 147 | expect(overlayContainerElement.textContent).toBe('', 'Subsequently closed'); 148 | })); 149 | 150 | it('should toggle with toggle()', fakeAsync(() => { 151 | fixture.detectChanges(); 152 | expect(overlayContainerElement.textContent).toBe('', 'Initially closed'); 153 | 154 | comp.popover.toggle(); 155 | expect(overlayContainerElement.textContent).toContain('Popover', 'Subsequently open'); 156 | 157 | comp.popover.toggle(); 158 | fixture.detectChanges(); 159 | tick(); 160 | expect(overlayContainerElement.textContent).toBe('', 'Closed after second toggle'); 161 | })); 162 | 163 | it('should emit when opened', fakeAsync(() => { 164 | fixture.detectChanges(); 165 | let popoverOpenedEvent = false; 166 | let popoverAfterOpenEvent = false; 167 | 168 | comp.popover.opened.subscribe(() => (popoverOpenedEvent = true)); 169 | comp.popover.afterOpen.subscribe(() => (popoverAfterOpenEvent = true)); 170 | 171 | comp.popover.open(); 172 | 173 | expect(popoverOpenedEvent).toBe(true, 'popoverOpened called'); 174 | expect(popoverAfterOpenEvent).toBe(false, 'popoverAfterOpen not yet called'); 175 | 176 | tick(); 177 | expect(popoverAfterOpenEvent).toBe(true, 'popoverAfterOpen called after animation'); 178 | })); 179 | 180 | it('should emit when closed', fakeAsync(() => { 181 | fixture.detectChanges(); 182 | comp.popover.open(); 183 | 184 | let popoverClosedEvent = false; 185 | let popoverAfterCloseEvent = false; 186 | 187 | comp.popover.closed.subscribe(() => (popoverClosedEvent = true)); 188 | comp.popover.afterClose.subscribe(() => (popoverAfterCloseEvent = true)); 189 | 190 | comp.popover.close(); 191 | fixture.detectChanges(); 192 | 193 | expect(popoverClosedEvent).toBe(true, 'popoverClosed called'); 194 | expect(popoverAfterCloseEvent).toBe(false, 'popoverAfterClose not yet called'); 195 | 196 | tick(); 197 | expect(popoverAfterCloseEvent).toBe(true, 'popoverAfterClose called after animation'); 198 | })); 199 | 200 | it('should emit a value when closed with a value', fakeAsync(() => { 201 | fixture.detectChanges(); 202 | comp.popover.open(); 203 | 204 | const secondTestVal = 'xyz789'; 205 | 206 | let popoverClosedValue; 207 | 208 | comp.popover.closed.subscribe((val) => (popoverClosedValue = val)); 209 | 210 | comp.popover.close(secondTestVal); 211 | fixture.detectChanges(); 212 | tick(); 213 | 214 | // Working when closed via popover api 215 | expect(popoverClosedValue).toBe(secondTestVal, 'popoverClosed with value - popover api'); 216 | })); 217 | 218 | it('should return whether the popover is presently open', fakeAsync(() => { 219 | fixture.detectChanges(); 220 | 221 | expect(comp.popover.isOpen()).toBe(false, 'Initially closed - popover'); 222 | 223 | comp.popover.open(); 224 | 225 | expect(comp.popover.isOpen()).toBe(true, 'Subsequently opened - popover'); 226 | 227 | comp.popover.close(); 228 | fixture.detectChanges(); 229 | tick(); 230 | 231 | expect(comp.popover.isOpen()).toBe(false, 'Finally closed - popover'); 232 | })); 233 | 234 | it('should provide a reference to the anchor element', fakeAsync(() => { 235 | fixture.detectChanges(); 236 | expect(comp.anchor.elementRef).toEqual(comp.anchorElement); 237 | })); 238 | 239 | it('should provide a reference to the popover element', () => { 240 | fixture.detectChanges(); 241 | expect(comp.anchor.popover).toBe(comp.popover); 242 | }); 243 | }); 244 | 245 | describe('using satPopoverAnchor input setter', () => { 246 | describe('opening and closing behavior', () => { 247 | let fixture: ComponentFixture; 248 | let comp: DirectiveAnchorForPopoverTestComponent; 249 | let overlayContainerElement: HTMLElement; 250 | 251 | beforeEach(() => { 252 | TestBed.configureTestingModule({ 253 | imports: [DirectiveAnchorForPopoverTestComponent, NoopAnimationsModule], 254 | providers: [ 255 | importProvidersFrom(SatPopoverModule), 256 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 257 | ] 258 | }); 259 | 260 | fixture = TestBed.createComponent(DirectiveAnchorForPopoverTestComponent); 261 | comp = fixture.componentInstance; 262 | 263 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 264 | }); 265 | 266 | afterEach(() => { 267 | document.body.removeChild(overlayContainerElement); 268 | }); 269 | 270 | it('should open with open()', () => { 271 | fixture.detectChanges(); 272 | expect(overlayContainerElement.textContent).toBe('', 'Initially closed'); 273 | comp.popover.open(); 274 | expect(overlayContainerElement.textContent).toContain('Popover', 'Subsequently open'); 275 | }); 276 | 277 | it('should close with close()', fakeAsync(() => { 278 | fixture.detectChanges(); 279 | comp.popover.open(); 280 | expect(overlayContainerElement.textContent).toContain('Popover', 'Initially open'); 281 | 282 | comp.popover.close(); 283 | fixture.detectChanges(); 284 | tick(); 285 | expect(overlayContainerElement.textContent).toBe('', 'Subsequently closed'); 286 | })); 287 | 288 | it('should provide a reference to the popover element', () => { 289 | fixture.detectChanges(); 290 | expect(comp.anchor.popover).toBe(comp.popover); 291 | }); 292 | }); 293 | }); 294 | 295 | describe('backdrop', () => { 296 | let fixture: ComponentFixture; 297 | let comp: BackdropPopoverTestComponent; 298 | let overlayContainerElement: HTMLElement; 299 | 300 | beforeEach(() => { 301 | TestBed.configureTestingModule({ 302 | imports: [BackdropPopoverTestComponent, NoopAnimationsModule], 303 | providers: [ 304 | importProvidersFrom(SatPopoverModule), 305 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 306 | ] 307 | }); 308 | 309 | fixture = TestBed.createComponent(BackdropPopoverTestComponent); 310 | comp = fixture.componentInstance; 311 | 312 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 313 | }); 314 | 315 | afterEach(() => { 316 | document.body.removeChild(overlayContainerElement); 317 | }); 318 | 319 | it('should have no backdrop by default', () => { 320 | fixture.detectChanges(); 321 | comp.popover.open(); 322 | 323 | const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); 324 | expect(backdrop).toBeFalsy(); 325 | }); 326 | 327 | it('should allow adding a transparent backdrop', () => { 328 | comp.backdrop = true; 329 | fixture.detectChanges(); 330 | comp.popover.open(); 331 | 332 | const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); 333 | expect(backdrop).toBeTruthy(); 334 | }); 335 | 336 | it('should emit an event when the backdrop is clicked', fakeAsync(() => { 337 | comp.backdrop = true; 338 | fixture.detectChanges(); 339 | comp.popover.open(); 340 | 341 | const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); 342 | expect(comp.clicks).toBe(0, 'not yet clicked'); 343 | 344 | backdrop.click(); 345 | fixture.detectChanges(); 346 | expect(comp.clicks).toBe(1, 'clicked once'); 347 | tick(500); 348 | })); 349 | 350 | it('should close when backdrop is clicked', fakeAsync(() => { 351 | comp.backdrop = true; 352 | fixture.detectChanges(); 353 | comp.popover.open(); 354 | 355 | const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); 356 | backdrop.click(); 357 | fixture.detectChanges(); 358 | tick(500); 359 | 360 | expect(overlayContainerElement.textContent).toBe(''); 361 | })); 362 | 363 | it('should not close when interactiveClose is false', fakeAsync(() => { 364 | comp.backdrop = true; 365 | comp.popover.interactiveClose = false; 366 | fixture.detectChanges(); 367 | comp.popover.open(); 368 | 369 | const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); 370 | expect(comp.clicks).toBe(0, 'Not yet clicked'); 371 | backdrop.click(); 372 | fixture.detectChanges(); 373 | tick(500); 374 | 375 | expect(overlayContainerElement.textContent).toContain('Popover', 'Interactive close disabled'); 376 | 377 | comp.popover.interactiveClose = true; 378 | backdrop.click(); 379 | fixture.detectChanges(); 380 | tick(500); 381 | 382 | expect(comp.clicks).toBe(2, 'Clicked twice'); 383 | expect(overlayContainerElement.textContent).toBe('', 'Interactive close allowed'); 384 | })); 385 | 386 | it('should allow a custom backdrop to be added', () => { 387 | comp.backdrop = true; 388 | comp.klass = 'test-custom-class'; 389 | fixture.detectChanges(); 390 | comp.popover.open(); 391 | 392 | const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); 393 | expect(backdrop.classList.contains('test-custom-class')).toBe(true); 394 | }); 395 | }); 396 | 397 | describe('keyboard', () => { 398 | let fixture: ComponentFixture; 399 | let comp: KeyboardPopoverTestComponent; 400 | let overlayContainerElement: HTMLElement; 401 | 402 | beforeEach(() => { 403 | TestBed.configureTestingModule({ 404 | imports: [KeyboardPopoverTestComponent, NoopAnimationsModule], 405 | providers: [ 406 | importProvidersFrom(SatPopoverModule), 407 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 408 | ] 409 | }); 410 | 411 | fixture = TestBed.createComponent(KeyboardPopoverTestComponent); 412 | comp = fixture.componentInstance; 413 | 414 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 415 | }); 416 | 417 | afterEach(() => { 418 | document.body.removeChild(overlayContainerElement); 419 | }); 420 | 421 | it('should close when escape key is pressed', fakeAsync(() => { 422 | fixture.detectChanges(); 423 | comp.popover.open(); 424 | 425 | // Let focus move to the first focusable element 426 | fixture.detectChanges(); 427 | tick(); 428 | 429 | expect(overlayContainerElement.textContent).toContain('Popover', 'Initially open'); 430 | 431 | // Emit ESCAPE keydown event 432 | const currentlyFocusedElement = document.activeElement; 433 | expect(currentlyFocusedElement.classList).toContain('first', 'Ensure input is focused'); 434 | currentlyFocusedElement.dispatchEvent(createKeyboardEvent('keydown', ESCAPE)); 435 | 436 | fixture.detectChanges(); 437 | tick(500); 438 | 439 | expect(overlayContainerElement.textContent).toBe('', 'Closed after escape keydown'); 440 | })); 441 | 442 | it('should not close when interactiveClose is false', fakeAsync(() => { 443 | comp.popover.interactiveClose = false; 444 | fixture.detectChanges(); 445 | comp.popover.open(); 446 | 447 | // Let focus move to the first focusable element 448 | fixture.detectChanges(); 449 | tick(); 450 | 451 | expect(overlayContainerElement.textContent).toContain('Popover', 'Initially open'); 452 | 453 | // Emit ESCAPE keydown event 454 | const currentlyFocusedElement = document.activeElement; 455 | expect(currentlyFocusedElement.classList).toContain('first', 'Ensure input is focused'); 456 | currentlyFocusedElement.dispatchEvent(createKeyboardEvent('keydown', ESCAPE)); 457 | 458 | fixture.detectChanges(); 459 | tick(500); 460 | 461 | expect(comp.lastKeyCode).toBe(ESCAPE, 'Keydown still captured'); 462 | expect(overlayContainerElement.textContent).toContain('Popover', 'Interactive close disabled'); 463 | 464 | comp.popover.interactiveClose = true; 465 | currentlyFocusedElement.dispatchEvent(createKeyboardEvent('keydown', ESCAPE)); 466 | fixture.detectChanges(); 467 | tick(500); 468 | 469 | expect(overlayContainerElement.textContent).toBe('', 'Interactive close allowed'); 470 | })); 471 | 472 | it('should emit keydown events when key is pressed', fakeAsync(() => { 473 | fixture.detectChanges(); 474 | comp.popover.open(); 475 | 476 | // Let focus move to the first focusable element 477 | fixture.detectChanges(); 478 | tick(); 479 | 480 | expect(comp.lastKeyCode).toBe(undefined, 'no key presses yet'); 481 | 482 | // Emit A keydown event on input element 483 | const currentlyFocusedElement = document.activeElement; 484 | currentlyFocusedElement.dispatchEvent(createKeyboardEvent('keydown', A)); 485 | 486 | fixture.detectChanges(); 487 | expect(comp.lastKeyCode).toBe(A, 'pressed A key on input'); 488 | 489 | // Emit ESCAPE keydown event on body 490 | document.body.dispatchEvent(createKeyboardEvent('keydown', ESCAPE)); 491 | fixture.detectChanges(); 492 | expect(comp.lastKeyCode).toBe(ESCAPE, 'pressed ESCAPE key on body'); 493 | 494 | tick(500); 495 | })); 496 | }); 497 | 498 | describe('focus', () => { 499 | let fixture: ComponentFixture; 500 | let comp: FocusPopoverTestComponent; 501 | let overlayContainerElement: HTMLElement; 502 | 503 | beforeEach(() => { 504 | TestBed.configureTestingModule({ 505 | imports: [FocusPopoverTestComponent, NoopAnimationsModule], 506 | providers: [ 507 | importProvidersFrom(SatPopoverModule), 508 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 509 | ] 510 | }); 511 | 512 | fixture = TestBed.createComponent(FocusPopoverTestComponent); 513 | comp = fixture.componentInstance; 514 | 515 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 516 | }); 517 | 518 | afterEach(() => { 519 | document.body.removeChild(overlayContainerElement); 520 | }); 521 | 522 | it('should focus the initial element by default', fakeAsync(() => { 523 | fixture.detectChanges(); 524 | comp.button1.nativeElement.focus(); 525 | comp.button1.nativeElement.click(); 526 | 527 | fixture.detectChanges(); 528 | tick(); 529 | 530 | expect(document.activeElement.classList).toContain('input', 'Ensure input is focused'); 531 | })); 532 | 533 | it('should not focus the initial element if autoFocus is false', fakeAsync(() => { 534 | comp.autoFocus = false; 535 | fixture.detectChanges(); 536 | 537 | comp.button1.nativeElement.focus(); 538 | comp.button1.nativeElement.click(); 539 | 540 | fixture.detectChanges(); 541 | tick(); 542 | 543 | expect(document.activeElement).toEqual(comp.button1.nativeElement); 544 | })); 545 | 546 | it('should not focus the initial element with autoFocus option as false', fakeAsync(() => { 547 | fixture.detectChanges(); 548 | comp.button1.nativeElement.focus(); 549 | comp.popover.open({ autoFocus: false }); 550 | 551 | fixture.detectChanges(); 552 | tick(); 553 | 554 | expect(document.activeElement).toEqual(comp.button1.nativeElement); 555 | })); 556 | 557 | it('should restore focus by default', fakeAsync(() => { 558 | fixture.detectChanges(); 559 | comp.button1.nativeElement.focus(); 560 | expect(document.activeElement.textContent).toBe('Button 1', 'Button 1 focus'); 561 | comp.popover.open(); 562 | 563 | fixture.detectChanges(); 564 | tick(); 565 | expect(document.activeElement.classList).toContain('input', 'Popover input is focused'); 566 | 567 | comp.button2.nativeElement.focus(); 568 | expect(document.activeElement.textContent).toBe('Button 2', 'Button 2 focused while open'); 569 | 570 | comp.popover.close(); 571 | fixture.detectChanges(); 572 | tick(); 573 | expect(document.activeElement.textContent).toBe('Button 1', 'Button 1 focus restored'); 574 | })); 575 | 576 | it('should not restore focus if restoreFocus as false', fakeAsync(() => { 577 | comp.restoreFocus = false; 578 | 579 | fixture.detectChanges(); 580 | comp.button1.nativeElement.focus(); 581 | expect(document.activeElement.textContent).toBe('Button 1', 'Button 1 focus'); 582 | comp.popover.open(); 583 | 584 | fixture.detectChanges(); 585 | tick(); 586 | expect(document.activeElement.classList).toContain('input', 'Popover input is focused'); 587 | 588 | comp.button2.nativeElement.focus(); 589 | expect(document.activeElement.textContent).toBe('Button 2', 'Button 2 focused while open'); 590 | 591 | comp.popover.close(); 592 | fixture.detectChanges(); 593 | tick(); 594 | expect(document.activeElement.textContent).toBe('Button 2', 'Button 2 remains focused'); 595 | })); 596 | 597 | it('should not restore focus when opened with restoreFocus option as false', fakeAsync(() => { 598 | fixture.detectChanges(); 599 | comp.button1.nativeElement.focus(); 600 | expect(document.activeElement.textContent).toBe('Button 1', 'Button 1 focus'); 601 | comp.popover.open({ restoreFocus: false }); 602 | 603 | fixture.detectChanges(); 604 | tick(); 605 | expect(document.activeElement.classList).toContain('input', 'Popover input is focused'); 606 | 607 | comp.button2.nativeElement.focus(); 608 | expect(document.activeElement.textContent).toBe('Button 2', 'Button 2 focused while open'); 609 | 610 | comp.popover.close(); 611 | fixture.detectChanges(); 612 | tick(); 613 | expect(document.activeElement.textContent).toBe('Button 2', 'Button 2 remains focused'); 614 | })); 615 | }); 616 | 617 | describe('positioning', () => { 618 | let fixture: ComponentFixture; 619 | let comp: PositioningTestComponent; 620 | let overlayContainerElement: HTMLElement; 621 | 622 | beforeEach(() => { 623 | TestBed.configureTestingModule({ 624 | imports: [PositioningTestComponent, PositioningAliasTestComponent, NoopAnimationsModule], 625 | providers: [ 626 | importProvidersFrom(SatPopoverModule), 627 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 628 | ] 629 | }); 630 | 631 | fixture = TestBed.createComponent(PositioningTestComponent); 632 | comp = fixture.componentInstance; 633 | 634 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 635 | }); 636 | 637 | afterEach(() => { 638 | document.body.removeChild(overlayContainerElement); 639 | }); 640 | 641 | it('should keep the same overlay when positions are static', fakeAsync(() => { 642 | fixture.detectChanges(); 643 | 644 | // open the overlay and store the overlayRef 645 | comp.popover.open(); 646 | const overlayAfterFirstOpen = comp.popover._anchoringService._overlayRef; 647 | 648 | comp.popover.close(); 649 | fixture.detectChanges(); 650 | tick(); 651 | 652 | // change the position to the same thing and reopen, saving the new overlayRef 653 | comp.hAlign = 'center'; 654 | fixture.detectChanges(); 655 | 656 | comp.popover.open(); 657 | const overlayAfterSecondOpen = comp.popover._anchoringService._overlayRef; 658 | 659 | expect(overlayAfterFirstOpen === overlayAfterSecondOpen).toBe(true); 660 | })); 661 | 662 | it('should reconstruct the overlay when positions are updated', fakeAsync(() => { 663 | fixture.detectChanges(); 664 | 665 | // open the overlay and store the overlayRef 666 | comp.popover.open(); 667 | const overlayAfterFirstOpen = comp.popover._anchoringService._overlayRef; 668 | 669 | comp.popover.close(); 670 | fixture.detectChanges(); 671 | tick(); 672 | 673 | // change the position and reopen, saving the new overlayRef 674 | comp.hAlign = 'after'; 675 | fixture.detectChanges(); 676 | 677 | comp.popover.open(); 678 | const overlayAfterSecondOpen = comp.popover._anchoringService._overlayRef; 679 | 680 | expect(overlayAfterFirstOpen === overlayAfterSecondOpen).toBe(false); 681 | })); 682 | 683 | it('should generate the correct number of positions', fakeAsync(() => { 684 | let strategy: FlexibleConnectedPositionStrategy; 685 | let overlayConfig: OverlayConfig; 686 | fixture.detectChanges(); 687 | 688 | // centered over anchor can be any of 5 x 5 positions 689 | comp.popover.open(); 690 | overlayConfig = comp.popover._anchoringService._overlayRef.getConfig(); 691 | strategy = overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy; 692 | expect(strategy.positions.length).toBe(25, 'overlapping'); 693 | 694 | comp.popover.close(); 695 | fixture.detectChanges(); 696 | tick(); 697 | 698 | // non-overlapping can be any of 2 x 2 positions 699 | comp.hAlign = 'after'; 700 | comp.vAlign = 'below'; 701 | fixture.detectChanges(); 702 | 703 | comp.popover.open(); 704 | overlayConfig = comp.popover._anchoringService._overlayRef.getConfig(); 705 | strategy = overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy; 706 | expect(strategy.positions.length).toBe(4, 'non-overlapping'); 707 | 708 | comp.popover.close(); 709 | fixture.detectChanges(); 710 | tick(); 711 | 712 | // overlapping in one direction can be any of 2 x 5 positions 713 | comp.hAlign = 'start'; 714 | comp.vAlign = 'below'; 715 | fixture.detectChanges(); 716 | 717 | comp.popover.open(); 718 | overlayConfig = comp.popover._anchoringService._overlayRef.getConfig(); 719 | strategy = overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy; 720 | expect(strategy.positions.length).toBe(10, 'overlapping in one dimension'); 721 | })); 722 | 723 | it('should throw an error when an invalid horizontalAlign is provided', () => { 724 | fixture.detectChanges(); 725 | 726 | // set invalid horizontalAlign 727 | comp.hAlign = 'kiwi'; 728 | 729 | expect(() => { 730 | fixture.detectChanges(); 731 | }).toThrow(getInvalidHorizontalAlignError('kiwi')); 732 | }); 733 | 734 | it('should throw an error when an invalid verticalAlign is provided', () => { 735 | fixture.detectChanges(); 736 | 737 | // set invalid verticalAlign 738 | comp.vAlign = 'banana'; 739 | 740 | expect(() => { 741 | fixture.detectChanges(); 742 | }).toThrow(getInvalidVerticalAlignError('banana')); 743 | }); 744 | 745 | it('should allow aliases for horizontal and vertical align inputs', () => { 746 | const aliasFixture = TestBed.createComponent(PositioningAliasTestComponent); 747 | const aliasComp = aliasFixture.componentInstance; 748 | 749 | aliasComp.xAlign = 'before'; 750 | aliasComp.yAlign = 'end'; 751 | 752 | aliasFixture.detectChanges(); 753 | 754 | expect(aliasComp.popover.horizontalAlign).toBe('before'); 755 | expect(aliasComp.popover.verticalAlign).toBe('end'); 756 | }); 757 | 758 | it('should only generate one position when force aligned', () => { 759 | comp.forceAlignment = true; 760 | fixture.detectChanges(); 761 | 762 | comp.popover.open(); 763 | const overlayConfig = comp.popover._anchoringService._overlayRef.getConfig(); 764 | const strategy = overlayConfig.positionStrategy as FlexibleConnectedPositionStrategy; 765 | expect(strategy.positions.length).toBe(1, 'only one position'); 766 | }); 767 | 768 | it('should lock the position when alignment is locked', fakeAsync(() => { 769 | // Note: this test relies on the internal logic of the FlexibleConnectedPositionStrategy 770 | // and is very brittle. 771 | fixture.detectChanges(); 772 | 773 | // Open the popover to get a spy on its position strategy 774 | comp.popover.open(); 775 | tick(); 776 | const firstOverlayConfig = comp.popover._anchoringService._overlayRef.getConfig(); 777 | const firstStrategy = firstOverlayConfig.positionStrategy as FlexibleConnectedPositionStrategy; 778 | const firstSpy = spyOn(firstStrategy, 'reapplyLastPosition'); 779 | 780 | // Emulate scrolling by calling apply. Assert the last position is not used when doing so. 781 | expect(firstSpy).not.toHaveBeenCalled(); 782 | firstStrategy.apply(); 783 | expect(firstSpy).not.toHaveBeenCalled(); 784 | 785 | // Close the popover and try again with `lockAlignment` 786 | comp.popover.close(); 787 | fixture.detectChanges(); 788 | tick(); 789 | 790 | comp.lockAlignment = true; 791 | fixture.detectChanges(); 792 | 793 | // Open the popover to get a spy on its position strategy 794 | comp.popover.open(); 795 | tick(); 796 | const secondOverlayConfig = comp.popover._anchoringService._overlayRef.getConfig(); 797 | const secondStrategy = secondOverlayConfig.positionStrategy as FlexibleConnectedPositionStrategy; 798 | const secondSpy = spyOn(secondStrategy, 'reapplyLastPosition'); 799 | 800 | // Assert that the strategy is new 801 | expect(firstStrategy).not.toBe(secondStrategy); 802 | 803 | // Emulate scrolling agin. Assert the last position is used. 804 | expect(secondSpy).not.toHaveBeenCalled(); 805 | secondStrategy.apply(); 806 | expect(secondSpy).toHaveBeenCalled(); 807 | })); 808 | 809 | it('should realign when the anchor moves', fakeAsync(() => { 810 | // Move the anchor off the left edge of the page 811 | const anchorEl = comp.anchor.elementRef.nativeElement; 812 | anchorEl.style.display = 'inline-block'; 813 | anchorEl.style.position = 'relative'; 814 | anchorEl.style.left = '50px'; 815 | 816 | fixture.detectChanges(); 817 | 818 | comp.popover.open(); 819 | fixture.detectChanges(); 820 | 821 | const getCenter = (clientRect) => { 822 | const value = clientRect.x + clientRect.width / 2; 823 | return Math.round((value + Number.EPSILON) * 100) / 100; 824 | }; 825 | const centerOfAnchor = () => getCenter(anchorEl.getBoundingClientRect()); 826 | const centerOfPopover = () => 827 | getCenter(overlayContainerElement.querySelector('.sat-popover-container').getBoundingClientRect()); 828 | 829 | // Expect popover to be centered over anchor 830 | expect(centerOfAnchor()).toBe(centerOfPopover(), 'Centered over anchor'); 831 | 832 | // Move anchor and expect center of popover to no longer be center of anchor 833 | anchorEl.style.left = '100px'; 834 | fixture.detectChanges(); 835 | expect(centerOfAnchor()).toBe(centerOfPopover() + 50, 'No longer centered over anchor'); 836 | 837 | // Realign popover and expect center of popover to now be center of anchor 838 | comp.popover.realign(); 839 | fixture.detectChanges(); 840 | expect(centerOfAnchor()).toBe(centerOfPopover(), 'Centered again after realign'); 841 | })); 842 | }); 843 | 844 | describe('scrolling', () => { 845 | let fixture: ComponentFixture; 846 | let comp: ScrollingTestComponent; 847 | let overlayContainerElement: HTMLElement; 848 | 849 | beforeEach(() => { 850 | TestBed.configureTestingModule({ 851 | imports: [ScrollingTestComponent, NoopAnimationsModule], 852 | providers: [ 853 | importProvidersFrom(SatPopoverModule), 854 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 855 | ] 856 | }); 857 | 858 | fixture = TestBed.createComponent(ScrollingTestComponent); 859 | comp = fixture.componentInstance; 860 | 861 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 862 | }); 863 | 864 | afterEach(() => { 865 | document.body.removeChild(overlayContainerElement); 866 | }); 867 | 868 | it('should allow changing the strategy dynamically', fakeAsync(() => { 869 | let strategy: ScrollStrategy; 870 | fixture.detectChanges(); 871 | comp.popover.open(); 872 | 873 | strategy = comp.popover._anchoringService._overlayRef.getConfig().scrollStrategy; 874 | expect(strategy instanceof RepositionScrollStrategy).toBe(true, 'reposition strategy'); 875 | 876 | comp.popover.close(); 877 | fixture.detectChanges(); 878 | tick(); 879 | 880 | comp.strategy = 'block'; 881 | fixture.detectChanges(); 882 | comp.popover.open(); 883 | 884 | strategy = comp.popover._anchoringService._overlayRef.getConfig().scrollStrategy; 885 | expect(strategy instanceof BlockScrollStrategy).toBe(true, 'block strategy'); 886 | })); 887 | 888 | it('should wait until the popover is closed to update the strategy', fakeAsync(() => { 889 | let strategy: ScrollStrategy; 890 | fixture.detectChanges(); 891 | comp.popover.open(); 892 | 893 | // expect it to be open with default strategy 894 | strategy = comp.popover._anchoringService._overlayRef.getConfig().scrollStrategy; 895 | expect(strategy instanceof RepositionScrollStrategy).toBe(true, 'reposition strategy'); 896 | expect(overlayContainerElement.textContent).toContain('Popover', 'initially open'); 897 | 898 | // change the strategy while it is open 899 | comp.strategy = 'block'; 900 | fixture.detectChanges(); 901 | tick(); 902 | 903 | // expect it to have remained open with default strategy 904 | strategy = comp.popover._anchoringService._overlayRef.getConfig().scrollStrategy; 905 | expect(strategy instanceof RepositionScrollStrategy).toBe(true, 'still reposition strategy'); 906 | expect(overlayContainerElement.textContent).toContain('Popover', 'Still open'); 907 | 908 | // close the popover and reopen 909 | comp.popover.close(); 910 | fixture.detectChanges(); 911 | tick(); 912 | comp.popover.open(); 913 | 914 | // expect the new strategy to be in place 915 | strategy = comp.popover._anchoringService._overlayRef.getConfig().scrollStrategy; 916 | expect(strategy instanceof BlockScrollStrategy).toBe(true, 'block strategy'); 917 | })); 918 | 919 | it('should throw an error when an invalid scrollStrategy is provided', () => { 920 | fixture.detectChanges(); 921 | 922 | // set invalid scrollStrategy 923 | comp.strategy = 'rambutan'; 924 | 925 | expect(() => { 926 | fixture.detectChanges(); 927 | }).toThrow(getInvalidScrollStrategyError('rambutan')); 928 | }); 929 | }); 930 | 931 | describe('anchoring service', () => { 932 | let fixture: ComponentFixture; 933 | let comp: ServiceTestComponent; 934 | let overlayContainerElement: HTMLElement; 935 | 936 | beforeEach(() => { 937 | TestBed.configureTestingModule({ 938 | imports: [ServiceTestComponent, NoopAnimationsModule], 939 | providers: [ 940 | importProvidersFrom(SatPopoverModule), 941 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 942 | ] 943 | }); 944 | 945 | fixture = TestBed.createComponent(ServiceTestComponent); 946 | comp = fixture.componentInstance; 947 | 948 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 949 | }); 950 | 951 | afterEach(() => { 952 | document.body.removeChild(overlayContainerElement); 953 | }); 954 | 955 | it('should throw an error if never anchored', () => { 956 | // should not throw just by initializing 957 | expect(() => { 958 | fixture.detectChanges(); 959 | }).not.toThrowError(); 960 | 961 | // should throw if trying to open 962 | expect(() => { 963 | comp.popover.open(); 964 | }).toThrow(getUnanchoredPopoverError()); 965 | }); 966 | 967 | it('should open via popover api after being anchored', () => { 968 | comp.popover.setCustomAnchor(comp.container, comp.customAnchor); 969 | fixture.detectChanges(); 970 | expect(overlayContainerElement.textContent).toBe('', 'Initially closed'); 971 | comp.popover.open(); 972 | expect(overlayContainerElement.textContent).toContain('Popover', 'Subsequently open'); 973 | }); 974 | 975 | it('should open via service api after being anchored', () => { 976 | comp.anchoring.anchor(comp.popover, comp.container, comp.customAnchor); 977 | fixture.detectChanges(); 978 | expect(overlayContainerElement.textContent).toBe('', 'Initially closed'); 979 | comp.anchoring.openPopover(); 980 | expect(overlayContainerElement.textContent).toContain('Popover', 'Subsequently open'); 981 | }); 982 | 983 | it('should get the anchor elementRef', () => { 984 | comp.anchoring.anchor(comp.popover, comp.container, comp.customAnchor); 985 | expect(comp.anchoring.getAnchorElement()).toEqual(comp.customAnchor.nativeElement); 986 | }); 987 | }); 988 | 989 | describe('hover directive', () => { 990 | let fixture: ComponentFixture; 991 | let comp: HoverDirectiveTestComponent; 992 | let overlayContainerElement: HTMLElement; 993 | 994 | beforeEach(() => { 995 | TestBed.configureTestingModule({ 996 | imports: [HoverDirectiveTestComponent, NoopAnimationsModule], 997 | providers: [ 998 | importProvidersFrom(SatPopoverModule), 999 | { provide: OverlayContainer, useFactory: overlayContainerFactory } 1000 | ] 1001 | }); 1002 | 1003 | fixture = TestBed.createComponent(HoverDirectiveTestComponent); 1004 | comp = fixture.componentInstance; 1005 | 1006 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 1007 | }); 1008 | 1009 | afterEach(() => { 1010 | document.body.removeChild(overlayContainerElement); 1011 | }); 1012 | 1013 | it('should open the popover when the anchor is hovered', fakeAsync(() => { 1014 | fixture.detectChanges(); 1015 | 1016 | comp.anchorEl.nativeElement.dispatchEvent(createMouseEvent('mouseenter')); 1017 | tick(1); 1018 | expect(comp.popover.isOpen()).toBe(true); 1019 | 1020 | comp.anchorEl.nativeElement.dispatchEvent(createMouseEvent('mouseleave')); 1021 | tick(1); 1022 | expect(comp.popover.isOpen()).toBe(false); 1023 | })); 1024 | 1025 | it('should open the popover after a delay', fakeAsync(() => { 1026 | comp.delay = 500; 1027 | fixture.detectChanges(); 1028 | 1029 | comp.anchorEl.nativeElement.dispatchEvent(createMouseEvent('mouseenter')); 1030 | tick(499); 1031 | expect(comp.popover.isOpen()).toBe(false); 1032 | tick(1); 1033 | expect(comp.popover.isOpen()).toBe(true); 1034 | 1035 | comp.anchorEl.nativeElement.dispatchEvent(createMouseEvent('mouseleave')); 1036 | expect(comp.popover.isOpen()).toBe(false); 1037 | })); 1038 | 1039 | it('should not open the popover if mouseleave event during delay', fakeAsync(() => { 1040 | comp.delay = 500; 1041 | fixture.detectChanges(); 1042 | 1043 | comp.anchorEl.nativeElement.dispatchEvent(createMouseEvent('mouseenter')); 1044 | tick(100); 1045 | expect(comp.popover.isOpen()).toBe(false); 1046 | 1047 | comp.anchorEl.nativeElement.dispatchEvent(createMouseEvent('mouseleave')); 1048 | expect(comp.popover.isOpen()).toBe(false); 1049 | 1050 | tick(400); 1051 | expect(comp.popover.isOpen()).toBe(false); 1052 | })); 1053 | }); 1054 | 1055 | describe('default transition', () => { 1056 | let fixture: ComponentFixture; 1057 | let comp: SimpleDirectiveAnchorPopoverTestComponent; 1058 | let overlayContainerElement: HTMLElement; 1059 | 1060 | beforeEach(() => { 1061 | TestBed.configureTestingModule({ 1062 | imports: [SimpleDirectiveAnchorPopoverTestComponent, NoopAnimationsModule], 1063 | providers: [importProvidersFrom(SatPopoverModule)] 1064 | }); 1065 | TestBed.overrideProvider(DEFAULT_TRANSITION, { 1066 | useValue: '300ms ease' 1067 | }); 1068 | 1069 | fixture = TestBed.createComponent(SimpleDirectiveAnchorPopoverTestComponent); 1070 | comp = fixture.componentInstance; 1071 | 1072 | overlayContainerElement = fixture.debugElement.injector.get(OverlayContainer).getContainerElement(); 1073 | }); 1074 | 1075 | afterEach(() => { 1076 | document.body.removeChild(overlayContainerElement); 1077 | }); 1078 | 1079 | it('should use the provided default transition', () => { 1080 | expect(comp.popover.openTransition).toBe('300ms ease'); 1081 | expect(comp.popover.closeTransition).toBe('300ms ease'); 1082 | }); 1083 | }); 1084 | }); 1085 | 1086 | /** 1087 | * This component is for testing that an anchor not associated with 1088 | * a popover will throw an error. 1089 | */ 1090 | @Component({ 1091 | imports: [SatPopoverModule], 1092 | template: `
` 1093 | }) 1094 | class InvalidAnchorTestComponent {} 1095 | 1096 | /** 1097 | * This component is for testing that passing an invalid anchor 1098 | * to a popover will throw an error. 1099 | */ 1100 | @Component({ 1101 | imports: [SatPopoverModule], 1102 | template: ` 1103 | Dummy 1104 | Dummy 1105 | ` 1106 | }) 1107 | class InvalidPopoverTestComponent {} 1108 | 1109 | /** 1110 | * This component is for testing that trying to open/close/toggle 1111 | * a popover with no anchor will throw an error. 1112 | */ 1113 | @Component({ 1114 | imports: [SatPopoverComponent], 1115 | template: ` Anchorless ` 1116 | }) 1117 | class AnchorlessPopoverTestComponent { 1118 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1119 | } 1120 | 1121 | /** 1122 | * This component is for testing the default behavior of a simple 1123 | * popover attached to a simple satPopoverAnchor anchor. 1124 | */ 1125 | @Component({ 1126 | imports: [SatPopoverAnchorDirective, SatPopoverComponent], 1127 | template: ` 1128 |
Anchor
1129 |
Alternate anchor
1130 | Popover 1131 | ` 1132 | }) 1133 | class SimpleDirectiveAnchorPopoverTestComponent { 1134 | @ViewChild('anchorEl') anchorElement: ElementRef; 1135 | @ViewChild('anchorEl2') alternateAnchorElement: ElementRef; 1136 | @ViewChild(SatPopoverAnchorDirective, { static: true }) anchor: SatPopoverAnchorDirective; 1137 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1138 | } 1139 | 1140 | /** 1141 | * This component is for testing the 1142 | * `SatPopoverAnchor#satPopoverAnchor` input setter. 1143 | */ 1144 | @Component({ 1145 | imports: [SatPopoverAnchorDirective, SatPopoverComponent], 1146 | template: ` 1147 |
Anchor
1148 |
Alternate anchor
1149 | Popover 1150 | ` 1151 | }) 1152 | class DirectiveAnchorForPopoverTestComponent { 1153 | @ViewChild('anchorEl') anchorElement: ElementRef; 1154 | @ViewChild('anchorEl2') alternateAnchorElement: ElementRef; 1155 | @ViewChild(SatPopoverAnchorDirective, { static: true }) anchor: SatPopoverAnchorDirective; 1156 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1157 | } 1158 | 1159 | /** 1160 | * This component is for testing the default behavior of a simple 1161 | * popover attached to a simple ElementRef anchor. 1162 | */ 1163 | @Component({ 1164 | imports: [SatPopoverComponent], 1165 | template: ` 1166 |
Anchor
1167 | Popover 1168 | ` 1169 | }) 1170 | class SimpleHTMLAnchorPopoverTestComponent { 1171 | @ViewChild('anchorEl') anchorElement: ElementRef; 1172 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1173 | } 1174 | 1175 | /** 1176 | * This component is for testing the backdrop behavior of a simple 1177 | * popover attached to a simple anchor. 1178 | */ 1179 | @Component({ 1180 | imports: [SatPopoverAnchorDirective, SatPopoverComponent], 1181 | template: ` 1182 |
Anchor
1183 | 1189 | Popover 1190 | 1191 | ` 1192 | }) 1193 | class BackdropPopoverTestComponent { 1194 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1195 | backdrop = false; 1196 | clicks = 0; 1197 | klass: string; 1198 | } 1199 | 1200 | /** 1201 | * This component is for testing behavior related to keyboard events 1202 | * inside the popover. 1203 | */ 1204 | @Component({ 1205 | imports: [SatPopoverAnchorDirective, SatPopoverComponent], 1206 | template: ` 1207 |
Anchor
1208 | 1209 | Popover 1210 | 1211 | 1212 | 1213 | ` 1214 | }) 1215 | export class KeyboardPopoverTestComponent { 1216 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1217 | lastKeyCode: number; 1218 | } 1219 | 1220 | /** 1221 | * This component is for testing focus behavior in the popover. 1222 | */ 1223 | @Component({ 1224 | imports: [SatPopoverAnchorDirective, SatPopoverComponent], 1225 | template: ` 1226 | 1227 | 1228 | 1229 | 1230 | 1231 | 1232 | ` 1233 | }) 1234 | export class FocusPopoverTestComponent { 1235 | restoreFocus = true; 1236 | autoFocus = true; 1237 | 1238 | @ViewChild('b1') button1: ElementRef; 1239 | @ViewChild('b2') button2: ElementRef; 1240 | @ViewChild('p') popover: SatPopoverComponent; 1241 | } 1242 | 1243 | /** This component is for testing dynamic positioning behavior. */ 1244 | @Component({ 1245 | imports: [SatPopoverAnchorDirective, SatPopoverComponent], 1246 | template: ` 1247 |
Anchor
1248 | 1255 | Popover 1256 | 1257 | ` 1258 | }) 1259 | export class PositioningTestComponent { 1260 | @ViewChild(SatPopoverAnchorDirective, { static: true }) anchor: SatPopoverAnchorDirective; 1261 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1262 | hAlign = 'center'; 1263 | vAlign = 'center'; 1264 | forceAlignment = false; 1265 | lockAlignment = false; 1266 | } 1267 | 1268 | /** This component is for testing position aliases. */ 1269 | @Component({ 1270 | imports: [SatPopoverAnchorDirective, SatPopoverComponent], 1271 | template: ` 1272 |
Anchor
1273 | Popover 1274 | ` 1275 | }) 1276 | export class PositioningAliasTestComponent { 1277 | @ViewChild(SatPopoverAnchorDirective, { static: true }) anchor: SatPopoverAnchorDirective; 1278 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1279 | xAlign = 'center'; 1280 | yAlign = 'center'; 1281 | } 1282 | 1283 | /** This component is for testing scroll behavior. */ 1284 | @Component({ 1285 | imports: [SatPopoverAnchorDirective, SatPopoverComponent], 1286 | template: ` 1287 |
Anchor
1288 | Popover 1289 | ` 1290 | }) 1291 | export class ScrollingTestComponent { 1292 | @ViewChild(SatPopoverAnchorDirective, { static: true }) anchor: SatPopoverAnchorDirective; 1293 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1294 | strategy = 'reposition'; 1295 | } 1296 | 1297 | /** This component is for testing the isolated anchoring service. */ 1298 | @Component({ 1299 | imports: [SatPopoverComponent], 1300 | template: ` 1301 |
Anchor
1302 | Popover 1303 | `, 1304 | providers: [SatPopoverAnchoringService] 1305 | }) 1306 | export class ServiceTestComponent { 1307 | @ViewChild('customAnchor', { static: true }) customAnchor: ElementRef; 1308 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1309 | 1310 | constructor( 1311 | public anchoring: SatPopoverAnchoringService, 1312 | public container: ViewContainerRef 1313 | ) {} 1314 | } 1315 | 1316 | /** This component is for testing the hover directive behavior. */ 1317 | @Component({ 1318 | imports: [SatPopoverAnchorDirective, SatPopoverComponent, SatPopoverHoverDirective], 1319 | template: ` 1320 |
Anchor
1321 | Popover 1322 | ` 1323 | }) 1324 | export class HoverDirectiveTestComponent { 1325 | @ViewChild('anchorEl') anchorEl: ElementRef; 1326 | @ViewChild(SatPopoverComponent, { static: true }) popover: SatPopoverComponent; 1327 | delay = 0; 1328 | } 1329 | 1330 | /** This factory function provides an overlay container under test control. */ 1331 | const overlayContainerFactory = () => { 1332 | const element = document.createElement('div'); 1333 | element.classList.add('cdk-overlay-container'); 1334 | document.body.appendChild(element); 1335 | 1336 | // remove body padding to keep consistent cross-browser 1337 | document.body.style.padding = '0'; 1338 | document.body.style.margin = '0'; 1339 | 1340 | return { getContainerElement: () => element }; 1341 | }; 1342 | 1343 | /** Dispatches a keydown event from an element. From angular/material2 */ 1344 | export function createKeyboardEvent(type: string, keyCode: number, target?: Element, key?: string) { 1345 | const event = new KeyboardEvent(key); 1346 | const initEventFn = event.initKeyboardEvent.bind(event); 1347 | const originalPreventDefault = event.preventDefault; 1348 | 1349 | initEventFn(type, true, true, window, 0, 0, 0, 0, 0, keyCode); 1350 | 1351 | // Webkit Browsers don't set the keyCode when calling the init function. 1352 | // See related bug https://bugs.webkit.org/show_bug.cgi?id=16735 1353 | Object.defineProperties(event, { 1354 | keyCode: { get: () => keyCode }, 1355 | key: { get: () => key }, 1356 | target: { get: () => target } 1357 | }); 1358 | 1359 | // IE won't set `defaultPrevented` on synthetic events so we need to do it manually. 1360 | event.preventDefault = function (...args: unknown[]) { 1361 | Object.defineProperty(event, 'defaultPrevented', { get: () => true }); 1362 | return originalPreventDefault.apply(this, args); 1363 | }; 1364 | 1365 | return event; 1366 | } 1367 | 1368 | export function createMouseEvent(type: string) { 1369 | const event = new MouseEvent(type, { 1370 | view: window, 1371 | bubbles: true, 1372 | cancelable: false, 1373 | detail: 0, 1374 | screenX: 0, 1375 | screenY: 0, 1376 | clientX: 0, 1377 | clientY: 0, 1378 | ctrlKey: false, 1379 | altKey: false, 1380 | shiftKey: false, 1381 | metaKey: false, 1382 | button: 0, 1383 | relatedTarget: null 1384 | }); 1385 | 1386 | return event; 1387 | } 1388 | --------------------------------------------------------------------------------