diff --git a/.angular-cli.json b/.angular-cli.json new file mode 100644 index 00000000000..549dd80bace --- /dev/null +++ b/.angular-cli.json @@ -0,0 +1,59 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "project": { + "name": "ng-zorro-antd" + }, + "apps": [ + { + "root": "src", + "outDir": "doc", + "assets": [ + "assets", + "favicon.ico" + ], + "index": "index.html", + "main": "main.ts", + "polyfills": "polyfills.ts", + "test": "test.ts", + "tsconfig": "tsconfig.app.json", + "testTsconfig": "tsconfig.spec.json", + "prefix": "nz", + "styles": [ + ], + "scripts": [], + "environmentSource": "environments/environment.ts", + "environments": { + "dev": "environments/environment.ts", + "prod": "environments/environment.prod.ts" + } + } + ], + "e2e": { + "protractor": { + "config": "./protractor.conf.js" + } + }, + "lint": [ + { + "project": "src/tsconfig.app.json", + "exclude": "**/node_modules/**" + }, + { + "project": "src/tsconfig.spec.json", + "exclude": "**/node_modules/**" + }, + { + "project": "e2e/tsconfig.e2e.json", + "exclude": "**/node_modules/**" + } + ], + "test": { + "karma": { + "config": "./karma.conf.js" + } + }, + "defaults": { + "styleExt": "less", + "component": {} + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..6e87a003da8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000000..1dc1b0e3330 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,56 @@ + + +## I'm submitting a... + +

+[ ] Bug report  
+[ ] Feature request
+[ ] Documentation issue or request
+[ ] Regression (a behavior that used to work and stopped working in a new release)
+[ ] Support request => Please do not submit support request here
+
+ +## Current behavior + + + +## Expected behavior + + + +## Minimal reproduction of the problem with instructions + + +## What is the motivation / use case for changing the behavior? + + + +## Environment + +

+Angular version: X.Y.Z
+
+ng-zorro-antd version: X.Y.Z
+
+Browser:
+- [ ] Chrome (desktop) version XX
+- [ ] Firefox version XX
+- [ ] Safari (desktop) version XX
+- [ ] IE version XX
+ 
+For Tooling issues:
+- Node version: XX  
+- Platform:  
+
+Others:
+
+
diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..9da9dabe526 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/doc +/tmp +/out-tsc + + + +/src/release +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +testem.log +/typings +yarn-error.log + +# e2e +/e2e/*.js +/e2e/*.map + +# System Files +.DS_Store +Thumbs.db diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000000..d2e8955c7c3 --- /dev/null +++ b/.npmignore @@ -0,0 +1,45 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp +/out-tsc + + +.github +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +testem.log +/typings +yarn-error.log + +# e2e +/e2e/*.js +/e2e/*.map + +# System Files +.DS_Store +Thumbs.db diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000000..94f31783748 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,31 @@ +sudo: required +dist: trusty +language: node_js +node_js: + - '6.10' +branches: + only: + - master + +addons: +apt: + sources: + - google-chrome + packages: + - google-chrome-stable + - google-chrome-beta + +before_install: + - export CHROME_BIN=chromium-browser + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start + +before_script: +- npm install -g @angular/cli +- npm install -g karma +- npm install +- ng build + +script: +- npm run test +- cat ./coverage/lcov.info | ./node_modules/.bin/coveralls diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000000..63ce9e4488f --- /dev/null +++ b/LICENSE @@ -0,0 +1,67 @@ +MIT LICENSE + +Copyright (c) 2017 Alibaba.com + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +MIT LICENSE + +Copyright (c) 2015-present Alipay.com, https://www.alipay.com/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +The MIT License + +Copyright (c) 2017 Google, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README-zh_CN.md b/README-zh_CN.md new file mode 100644 index 00000000000..55938ce1170 --- /dev/null +++ b/README-zh_CN.md @@ -0,0 +1,62 @@ +

+ + + +

+ +# NG-ZORRO +[![Build Status](https://travis-ci.org/NG-ZORRO/ng-zorro-antd.svg?branch=master)](https://travis-ci.org/NG-ZORRO/ng-zorro-antd) +[![Gitter](https://badges.gitter.im/ng-zorro/ng-zorro-antd.svg)](https://gitter.im/ng-zorro/ng-zorro-antd?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) + +这里是 Ant Design 的 Angular 4.0 实现,开发和服务于企业级后台产品。 + +[README in English](README.md) + +## 特性 + +- 提炼自企业级中后台产品的交互语言和视觉风格,定期与Ant Design React版本保持更新一致。 +- 开箱即用的高质量 Angular 组件。 +- 使用 TypeScript 构建,提供完整的类型定义文件。 + +## 支持环境 + +* 现代浏览器和 IE9 以上(需要 [polyfills](https://angular.cn/guide/browser-support))。 + +## 兼容版本 + +当前支持Angular`^4.0.0`版本 + + +## 安装 + +**我们推荐使用 npm 方式进行开发**,不仅可在开发环境轻松调试,也可放心地在生产环境打包部署使用,享受整个生态圈和工具链带来的诸多好处。 + +```bash +$ npm install ng-zorro-antd --save +``` + +如果你的网络环境不佳,推荐使用 [cnpm](https://github.com/cnpm/cnpm)。 + +## 标准开发 + +实际项目开发中,你会需要对 TypeScript 代码的构建、调试、代理、打包部署等一系列工程化的需求。 +我们推荐官方的 `@angular/cli` 工具链辅助进行开发 + +如果你想了解更多CLI工具链的功能和命令,建议访问[Angular CLI](https://github.com/angular/angular-cli)了解更多 + + + +## 链接 + +- [首页](http://ng.ant.design) +- [Angular官方文档](https://angular.cn/) +- [开发脚手架](https://cli.angular.io/) +- [TypeScript](https://www.typescriptlang.org/) +- [RxJS 5](https://github.com/ReactiveX/rxjs) + + +## 如何贡献 + +如果你希望参与贡献,欢迎 [Pull Request](https://github.com/NG-ZORRO/ng-zorro-antd/pulls),或给我们 [报告 Bug](https://github.com/NG-ZORRO/ng-zorro-antd/issues)。 + +> 强烈推荐阅读 [《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way)、[《如何向开源社区提问题》](https://github.com/seajs/seajs/issues/545) 和 [《如何有效地报告 Bug》](http://www.chiark.greenend.org.uk/%7Esgtatham/bugs-cn.html)、[《如何向开源项目提交无法解答的问题》](https://zhuanlan.zhihu.com/p/25795393),更好的问题更容易获得帮助。 diff --git a/README.md b/README.md new file mode 100644 index 00000000000..09d8139ada8 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +

+ + + +

+ +# NG-ZORRO +[![Build Status](https://travis-ci.org/NG-ZORRO/ng-zorro-antd.svg?branch=master)](https://travis-ci.org/NG-ZORRO/ng-zorro-antd) +[![Gitter](https://badges.gitter.im/ng-zorro/ng-zorro-antd.svg)](https://gitter.im/ng-zorro/ng-zorro-antd?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) + +An enterprise-class UI components based on Ant Design and Angular. + +[中文 README](README-zh_CN.md) + + +## Features + +- An enterprise-class UI design language for web applications. +- A set of high-quality Angular components out of the box. +- Written in TypeScript with complete define types. + +## Environment Support + +* Modern browsers and Internet Explorer 9+(with [polyfills](https://angular.cn/guide/browser-support))。 + +## Angular Version Support + +* Angular`^4.0.0` + + +## Install + +```bash +$ npm install ng-zorro-antd --save +``` + +## Development + +```bash +$ git clone git@github.com:NG-ZORRO/ng-zorro-antd.git +$ npm install +$ npm start +``` + + +## Links + +- [Home page](http://ng.ant.design) +- [Angular](https://angular.io/) +- [Angular CLI](https://cli.angular.io/) +- [TypeScript](https://www.typescriptlang.org/) +- [RxJS 5](https://github.com/ReactiveX/rxjs) + + +## Contributing + +You can submit any ideas as [pull request](https://github.com/NG-ZORRO/ng-zorro-antd/pulls),or as [GitHub issues](https://github.com/NG-ZORRO/ng-zorro-antd/issues)。 diff --git a/build.sh b/build.sh new file mode 100755 index 00000000000..f02f706939a --- /dev/null +++ b/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +readonly currentDir=$(cd $(dirname $0); pwd) +cd ${currentDir} +rm -rf src/release +cp -r src/components src/__gen_components +node ./less.convert.js +cd src/__gen_components && ../../node_modules/@angular/compiler-cli/src/main.js +cd .. && rm -rf __gen_components diff --git a/e2e/app.e2e-spec.ts b/e2e/app.e2e-spec.ts new file mode 100644 index 00000000000..297f0237cc0 --- /dev/null +++ b/e2e/app.e2e-spec.ts @@ -0,0 +1,14 @@ +import { AppPage } from './app.po'; + +describe('ng-zorro-antd App', () => { + let page: AppPage; + + beforeEach(() => { + page = new AppPage(); + }); + + it('should display welcome message', () => { + page.navigateTo(); + expect(page.getParagraphText()).toEqual('Welcome to app!'); + }); +}); diff --git a/e2e/app.po.ts b/e2e/app.po.ts new file mode 100644 index 00000000000..82ea75ba504 --- /dev/null +++ b/e2e/app.po.ts @@ -0,0 +1,11 @@ +import { browser, by, element } from 'protractor'; + +export class AppPage { + navigateTo() { + return browser.get('/'); + } + + getParagraphText() { + return element(by.css('app-root h1')).getText(); + } +} diff --git a/e2e/tsconfig.e2e.json b/e2e/tsconfig.e2e.json new file mode 100644 index 00000000000..1d9e5edf096 --- /dev/null +++ b/e2e/tsconfig.e2e.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/e2e", + "baseUrl": "./", + "module": "commonjs", + "target": "es5", + "types": [ + "jasmine", + "jasminewd2", + "node" + ] + } +} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 00000000000..5daf4eba658 Binary files /dev/null and b/favicon.ico differ diff --git a/index.showcase.ts b/index.showcase.ts new file mode 100644 index 00000000000..7f810cb6881 --- /dev/null +++ b/index.showcase.ts @@ -0,0 +1,2 @@ +// [NOTE] Temporary solution for local developing (ONLY used by "showcase/*") +export * from './src/components/ng-zorro-antd.module'; diff --git a/index.ts b/index.ts new file mode 100644 index 00000000000..7e3898fb320 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +export * from './src/release/ng-zorro-antd.module'; diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 00000000000..af139fada36 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,33 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular/cli'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular/cli/plugins/karma') + ], + client:{ + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + coverageIstanbulReporter: { + reports: [ 'html', 'lcovonly' ], + fixWebpackSourcePaths: true + }, + angularCli: { + environment: 'dev' + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false + }); +}; diff --git a/less.convert.js b/less.convert.js new file mode 100644 index 00000000000..7bd88420642 --- /dev/null +++ b/less.convert.js @@ -0,0 +1,115 @@ +"use strict"; + +let fs = require('fs'); +let pathUtil = require('path'); +let less = require("less"); + +let genDistPath = pathUtil.join(__dirname, 'src', '__gen_components', 'release'); +let genPath = pathUtil.join(__dirname, 'src', '__gen_components'); +let lessFilePool = []; +let handledLessFileCount = 0; + +let tsFileTester = /\.ts$/; +let stylesRegex = /styleUrls *:(\s*\[[^\]]*?\])/g; +let stringRegex = /(['"])((?:[^\\]\\\1|.)*?)\1/g; +let lessNumRegex = /style_(\d+)_less/g; + +function getTsFile(path, parse) { + try { + if (fs.statSync(path).isFile() && tsFileTester.test(path)) { + parse(path) + } else if (fs.statSync(path).isDirectory() && path.indexOf(genDistPath) < 0) { + // 单是一个文件夹且不是dist文件夹的情况下 + let paths = fs.readdirSync(path); + paths.forEach(function (p) { + getTsFile(pathUtil.join(path, p), parse); + }) + } + } catch (err) { + throw err; + } +} + +function transformStyleUrls(path) { + let content = fs.readFileSync(path); + if (stylesRegex.test(content)) { + let contentTemp = content.toString().replace(stylesRegex, function (match, urls) { + return "styles:" + urls.replace(stringRegex, function (match, quote, url) { + lessFilePool.push(pathUtil.resolve(pathUtil.dirname(path), url)) + let result = 'style_' + handledLessFileCount + '_less'; + handledLessFileCount += 1; + return result; + }) + }) + fs.writeFileSync(path, contentTemp); + } +} + +function doneOne() { + handledLessFileCount += 1; + // 说明所有处理完成。 + if (handledLessFileCount === lessFilePool.length) { + writeBack(); + } +} + +function writeBack() { + console.log("start to write back"); + getTsFile(genPath, writeBackCss); + console.log('Done'); +} + +function writeBackCss(path) { + let content = fs.readFileSync(path); + if (lessNumRegex.test(content)) { + let contentTemp = content.toString().replace(lessNumRegex, function (match, index) { + return '`' + lessFilePool[index] + '`'; + }); + fs.writeFileSync(path, contentTemp); + } +} + +function processLess() { + let index = 0; + while (index < lessFilePool.length) { + (function (index) { + // debugger + fs.readFile(lessFilePool[index], function (e, data) { + less.render(data.toString(), { + filename: lessFilePool[index] + }, function (e, output) { + lessFilePool[index] = output.css.replace(/\\e/g, function (match, e) { + // 对content中的类似'\e630'中的\e进行处理 + return '\\\\e'; + }).replace(/\\E/g, function (match, e) { + // 对content中的类似'\E630'中的\E进行处理 + return '\\\\E'; + }).replace(/\\20/g, function (match, e) { + // 对content中的类似'\20'中的\20进行处理 + return '\\\\20'; + }).replace(/`/g, function (match, e) { + // 处理css中`符号 + return "'"; + }); + doneOne(); + }) + }) + })(index); + index += 1 + } +} + +function process() { + // 把所有ts文件,引入的less文件的完整路径放到全局list里面, 并且对源文件进行占坑 + getTsFile(genPath, transformStyleUrls); + // 重置文件处理进度的计数器 + handledLessFileCount = 0; + // 对list里面的每一个less文件进行翻译并触发css回写 + console.log("start to translate from less 2 css"); + processLess(); +} + +console.log('prepare...'); +// 转换操作 +process(); + diff --git a/package.json b/package.json new file mode 100644 index 00000000000..c2d38879e89 --- /dev/null +++ b/package.json @@ -0,0 +1,83 @@ +{ + "name": "ng-zorro-antd", + "version": "0.5.0-rc.0", + "license": "MIT", + "description": "An enterprise-class UI components based on Ant Design and Angular", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build --prod", + "doc": "ng build --prod", + "release": "./build.sh && ng build --prod", + "test": "node --max_old_space_size=5120 ./node_modules/@angular/cli/bin/ng test --single-run --code-coverage", + "lint": "ng lint", + "e2e": "ng e2e" + }, + "main": "./index.ts", + "keywords": [ + "ant", + "design", + "angular", + "ui", + "framework", + "frontend" + ], + "homepage": "https://ng.ant.design", + "repository": { + "type": "git", + "url": "https://github.com/NG-ZORRO/ng-zorro-antd" + }, + "bugs": { + "url": "https://github.com/NG-ZORRO/ng-zorro-antd/issues" + }, + "dependencies": { + "moment": "^2.18.1", + "@angular/cdk": "^2.0.0-beta.8", + "@angular/animations": "^4.0.0", + "@angular/common": "^4.0.0", + "@angular/core": "^4.0.0", + "@angular/forms": "^4.0.0", + "@angular/platform-browser": "^4.0.0", + "rxjs": "^5.0.1", + "zone.js": "^0.8.14" + }, + "devDependencies": { + "@angular/cli": "1.3.0", + "@angular/compiler": "^4.0.0", + "@angular/compiler-cli": "^4.0.0", + "@angular/http": "^4.0.0", + "@angular/language-service": "^4.0.0", + "@angular/platform-browser-dynamic": "^4.0.0", + "@angular/router": "^4.0.0", + "@types/jasmine": "~2.5.53", + "@types/jasminewd2": "~2.0.2", + "@types/node": "~6.0.60", + "classlist.js": "^1.1.20150312", + "codelyzer": "~3.1.1", + "core-js": "^2.4.1", + "coveralls": "^2.13.1", + "highlight.js": "^9.12.0", + "intl": "^1.2.5", + "jasmine-core": "~2.6.2", + "jasmine-spec-reporter": "~4.1.0", + "karma": "~1.7.0", + "karma-chrome-launcher": "~2.1.1", + "karma-cli": "~1.0.1", + "karma-coverage-istanbul-reporter": "^1.2.1", + "karma-jasmine": "~1.1.0", + "karma-jasmine-html-reporter": "^0.2.2", + "marked": "^0.3.6", + "protractor": "~5.1.2", + "ts-node": "~3.2.0", + "tslint": "~5.3.2", + "typescript": "~2.3.3", + "web-animations-js": "^2.3.1" + }, + "peerDependencies": { + "moment": "^2.18.1", + "@angular/cdk": "^2.0.0-beta.8", + "@angular/core": "^4.0.0", + "@angular/common": "^4.0.0", + "@angular/forms": "^4.0.0" + } +} diff --git a/protractor.conf.js b/protractor.conf.js new file mode 100644 index 00000000000..7ee3b5ee863 --- /dev/null +++ b/protractor.conf.js @@ -0,0 +1,28 @@ +// Protractor configuration file, see link for more information +// https://github.com/angular/protractor/blob/master/lib/config.ts + +const { SpecReporter } = require('jasmine-spec-reporter'); + +exports.config = { + allScriptsTimeout: 11000, + specs: [ + './e2e/**/*.e2e-spec.ts' + ], + capabilities: { + 'browserName': 'chrome' + }, + directConnect: true, + baseUrl: 'http://localhost:4200/', + framework: 'jasmine', + jasmineNodeOpts: { + showColors: true, + defaultTimeoutInterval: 30000, + print: function() {} + }, + onPrepare() { + require('ts-node').register({ + project: 'e2e/tsconfig.e2e.json' + }); + jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); + } +}; diff --git a/source.ts b/source.ts new file mode 100644 index 00000000000..85d35b27170 --- /dev/null +++ b/source.ts @@ -0,0 +1,2 @@ +// export source file +export * from './src/components/ng-zorro-antd.module'; diff --git a/src/assets/download/fonts.zip b/src/assets/download/fonts.zip new file mode 100644 index 00000000000..62913b204c6 Binary files /dev/null and b/src/assets/download/fonts.zip differ diff --git a/src/assets/fonts/Lato/Lato-Black.ttf b/src/assets/fonts/Lato/Lato-Black.ttf new file mode 100644 index 00000000000..f839e5c7ca7 Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Black.ttf differ diff --git a/src/assets/fonts/Lato/Lato-Black.woff b/src/assets/fonts/Lato/Lato-Black.woff new file mode 100644 index 00000000000..d68dc376d00 Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Black.woff differ diff --git a/src/assets/fonts/Lato/Lato-Black.woff2 b/src/assets/fonts/Lato/Lato-Black.woff2 new file mode 100644 index 00000000000..0cd657290a5 Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Black.woff2 differ diff --git a/src/assets/fonts/Lato/Lato-Light.ttf b/src/assets/fonts/Lato/Lato-Light.ttf new file mode 100644 index 00000000000..13fdee5a1e5 Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Light.ttf differ diff --git a/src/assets/fonts/Lato/Lato-Light.woff b/src/assets/fonts/Lato/Lato-Light.woff new file mode 100644 index 00000000000..d645e0e97e3 Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Light.woff differ diff --git a/src/assets/fonts/Lato/Lato-Light.woff2 b/src/assets/fonts/Lato/Lato-Light.woff2 new file mode 100644 index 00000000000..f0611d5c9af Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Light.woff2 differ diff --git a/src/assets/fonts/Lato/Lato-Regular.eot b/src/assets/fonts/Lato/Lato-Regular.eot new file mode 100644 index 00000000000..0dd6542b0e9 Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Regular.eot differ diff --git a/src/assets/fonts/Lato/Lato-Regular.svg b/src/assets/fonts/Lato/Lato-Regular.svg new file mode 100644 index 00000000000..55b43fb86a0 --- /dev/null +++ b/src/assets/fonts/Lato/Lato-Regular.svg @@ -0,0 +1,435 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/fonts/Lato/Lato-Regular.ttf b/src/assets/fonts/Lato/Lato-Regular.ttf new file mode 100644 index 00000000000..acc89d61194 Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Regular.ttf differ diff --git a/src/assets/fonts/Lato/Lato-Regular.woff b/src/assets/fonts/Lato/Lato-Regular.woff new file mode 100644 index 00000000000..cffe912751b Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Regular.woff differ diff --git a/src/assets/fonts/Lato/Lato-Regular.woff2 b/src/assets/fonts/Lato/Lato-Regular.woff2 new file mode 100644 index 00000000000..63ea29229a6 Binary files /dev/null and b/src/assets/fonts/Lato/Lato-Regular.woff2 differ diff --git a/src/assets/fonts/Raleway/OFL.txt b/src/assets/fonts/Raleway/OFL.txt new file mode 100755 index 00000000000..1c9779ddcd0 --- /dev/null +++ b/src/assets/fonts/Raleway/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2010, Matt McInerney (matt@pixelspread.com), +Copyright (c) 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com), +Copyright (c) 2011, Rodrigo Fuenzalida (www.rfuenzalida.com|hello@rfuenzalida.com), with Reserved Font Name Raleway +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/fonts/Raleway/Raleway-Black.ttf b/src/assets/fonts/Raleway/Raleway-Black.ttf new file mode 100755 index 00000000000..c5fe5ebe253 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Black.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-Black.woff b/src/assets/fonts/Raleway/Raleway-Black.woff new file mode 100644 index 00000000000..9c98fb46c0b Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Black.woff differ diff --git a/src/assets/fonts/Raleway/Raleway-Black.woff2 b/src/assets/fonts/Raleway/Raleway-Black.woff2 new file mode 100644 index 00000000000..ee07085f5e9 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Black.woff2 differ diff --git a/src/assets/fonts/Raleway/Raleway-BlackItalic.ttf b/src/assets/fonts/Raleway/Raleway-BlackItalic.ttf new file mode 100755 index 00000000000..2b2e65c004f Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-BlackItalic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-Bold.ttf b/src/assets/fonts/Raleway/Raleway-Bold.ttf new file mode 100755 index 00000000000..38c099cc857 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Bold.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-BoldItalic.ttf b/src/assets/fonts/Raleway/Raleway-BoldItalic.ttf new file mode 100755 index 00000000000..eac54e782bc Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-BoldItalic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-ExtraBold.ttf b/src/assets/fonts/Raleway/Raleway-ExtraBold.ttf new file mode 100755 index 00000000000..502ff863817 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-ExtraBold.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-ExtraBoldItalic.ttf b/src/assets/fonts/Raleway/Raleway-ExtraBoldItalic.ttf new file mode 100755 index 00000000000..09e0243fee7 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-ExtraBoldItalic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-ExtraLight.ttf b/src/assets/fonts/Raleway/Raleway-ExtraLight.ttf new file mode 100755 index 00000000000..7611e96c328 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-ExtraLight.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-ExtraLightItalic.ttf b/src/assets/fonts/Raleway/Raleway-ExtraLightItalic.ttf new file mode 100755 index 00000000000..3d07c1e7a8c Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-ExtraLightItalic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-Italic.ttf b/src/assets/fonts/Raleway/Raleway-Italic.ttf new file mode 100755 index 00000000000..237d88d9011 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Italic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-Light.ttf b/src/assets/fonts/Raleway/Raleway-Light.ttf new file mode 100755 index 00000000000..e6c9a8d8b02 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Light.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-Light.woff b/src/assets/fonts/Raleway/Raleway-Light.woff new file mode 100644 index 00000000000..956f619a3f6 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Light.woff differ diff --git a/src/assets/fonts/Raleway/Raleway-Light.woff2 b/src/assets/fonts/Raleway/Raleway-Light.woff2 new file mode 100644 index 00000000000..cb25b8db376 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Light.woff2 differ diff --git a/src/assets/fonts/Raleway/Raleway-LightItalic.ttf b/src/assets/fonts/Raleway/Raleway-LightItalic.ttf new file mode 100755 index 00000000000..7ba0de815bf Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-LightItalic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-Medium.ttf b/src/assets/fonts/Raleway/Raleway-Medium.ttf new file mode 100755 index 00000000000..7a71a6ff031 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Medium.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-MediumItalic.ttf b/src/assets/fonts/Raleway/Raleway-MediumItalic.ttf new file mode 100755 index 00000000000..43ed49a19b4 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-MediumItalic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-Regular.ttf b/src/assets/fonts/Raleway/Raleway-Regular.ttf new file mode 100755 index 00000000000..e570a2d5c39 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Regular.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-SemiBold.ttf b/src/assets/fonts/Raleway/Raleway-SemiBold.ttf new file mode 100755 index 00000000000..a2f980b6b98 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-SemiBold.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-SemiBold.woff b/src/assets/fonts/Raleway/Raleway-SemiBold.woff new file mode 100644 index 00000000000..dabd35c73b2 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-SemiBold.woff differ diff --git a/src/assets/fonts/Raleway/Raleway-SemiBold.woff2 b/src/assets/fonts/Raleway/Raleway-SemiBold.woff2 new file mode 100644 index 00000000000..80748034a73 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-SemiBold.woff2 differ diff --git a/src/assets/fonts/Raleway/Raleway-SemiBoldItalic.ttf b/src/assets/fonts/Raleway/Raleway-SemiBoldItalic.ttf new file mode 100755 index 00000000000..36b79515419 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-SemiBoldItalic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-Thin.ttf b/src/assets/fonts/Raleway/Raleway-Thin.ttf new file mode 100755 index 00000000000..a497b98a868 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-Thin.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway-ThinItalic.ttf b/src/assets/fonts/Raleway/Raleway-ThinItalic.ttf new file mode 100755 index 00000000000..38008b9af58 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway-ThinItalic.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway.eot b/src/assets/fonts/Raleway/Raleway.eot new file mode 100644 index 00000000000..1c45e815484 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway.eot differ diff --git a/src/assets/fonts/Raleway/Raleway.svg b/src/assets/fonts/Raleway/Raleway.svg new file mode 100644 index 00000000000..35870707df8 --- /dev/null +++ b/src/assets/fonts/Raleway/Raleway.svg @@ -0,0 +1,347 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/fonts/Raleway/Raleway.ttf b/src/assets/fonts/Raleway/Raleway.ttf new file mode 100644 index 00000000000..71477085ca6 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway.ttf differ diff --git a/src/assets/fonts/Raleway/Raleway.woff b/src/assets/fonts/Raleway/Raleway.woff new file mode 100644 index 00000000000..84e4bc50113 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway.woff differ diff --git a/src/assets/fonts/Raleway/Raleway.woff2 b/src/assets/fonts/Raleway/Raleway.woff2 new file mode 100644 index 00000000000..7cb70315ff1 Binary files /dev/null and b/src/assets/fonts/Raleway/Raleway.woff2 differ diff --git a/src/assets/fonts/iconfont.eot b/src/assets/fonts/iconfont.eot new file mode 100644 index 00000000000..d918a5477d2 Binary files /dev/null and b/src/assets/fonts/iconfont.eot differ diff --git a/src/assets/fonts/iconfont.svg b/src/assets/fonts/iconfont.svg new file mode 100644 index 00000000000..a9583b24e06 --- /dev/null +++ b/src/assets/fonts/iconfont.svg @@ -0,0 +1,923 @@ + + + + +Created by FontForge 20120731 at Fri Mar 17 19:08:59 2017 + By admin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/fonts/iconfont.ttf b/src/assets/fonts/iconfont.ttf new file mode 100644 index 00000000000..0ebc42fb7be Binary files /dev/null and b/src/assets/fonts/iconfont.ttf differ diff --git a/src/assets/fonts/iconfont.woff b/src/assets/fonts/iconfont.woff new file mode 100644 index 00000000000..4241815a760 Binary files /dev/null and b/src/assets/fonts/iconfont.woff differ diff --git a/src/assets/img/angular.svg b/src/assets/img/angular.svg new file mode 100644 index 00000000000..bf081acb129 --- /dev/null +++ b/src/assets/img/angular.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/assets/img/antd.svg b/src/assets/img/antd.svg new file mode 100644 index 00000000000..c03bd4ae9b8 --- /dev/null +++ b/src/assets/img/antd.svg @@ -0,0 +1,32 @@ + + + + a + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/img/zorro.png b/src/assets/img/zorro.png new file mode 100644 index 00000000000..241fd70249a Binary files /dev/null and b/src/assets/img/zorro.png differ diff --git a/src/assets/img/zorro.svg b/src/assets/img/zorro.svg new file mode 100644 index 00000000000..efe4edd1dc6 --- /dev/null +++ b/src/assets/img/zorro.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/alert/nz-alert.component.ts b/src/components/alert/nz-alert.component.ts new file mode 100644 index 00000000000..138fcd20622 --- /dev/null +++ b/src/components/alert/nz-alert.component.ts @@ -0,0 +1,89 @@ +/** + * @author MoXun + */ +import { + Component, + ViewEncapsulation, + Input, + Output, + EventEmitter +} from '@angular/core'; +import { FadeAnimation } from '../core/animation/fade-animations'; + +@Component({ + selector : 'nz-alert', + encapsulation: ViewEncapsulation.None, + animations : [ FadeAnimation ], + template : ` +
+ + + + {{nzMessage}} + + + + + + {{nzDescription}} + + + + + + + + + {{nzCloseText}} + +
+ `, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) + +export class NzAlertComponent { + _display = true; + @Input() nzType = 'info'; + @Input() nzBanner = false; + @Input() nzCloseable = false; + @Input() nzDescription: string; + @Input() nzShowIcon = false; + @Input() nzCloseText: string; + @Input() nzMessage: string; + @Output() nzOnClose: EventEmitter = new EventEmitter(); + + get _classMap() { + const antAlert = 'ant-alert'; + return { + [`${antAlert}`] : true, + [`${antAlert}-${this.nzType}`] : true, + [`${antAlert}-no-icon`] : !this.nzShowIcon, + [`${antAlert}-banner`] : this.nzBanner, + [`${antAlert}-with-description`]: !!this.nzDescription + }; + } + + closeAlert(): void { + this._display = false; + this.nzOnClose.emit(true); + } + + constructor() { + } + +} diff --git a/src/components/alert/nz-alert.module.ts b/src/components/alert/nz-alert.module.ts new file mode 100644 index 00000000000..28c6db1019d --- /dev/null +++ b/src/components/alert/nz-alert.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { NzAlertComponent } from './nz-alert.component'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + declarations: [ NzAlertComponent ], + exports : [ NzAlertComponent ], + imports : [ CommonModule ] +}) +export class NzAlertModule { +} diff --git a/src/components/alert/style/index.less b/src/components/alert/style/index.less new file mode 100755 index 00000000000..ababa3b9d7b --- /dev/null +++ b/src/components/alert/style/index.less @@ -0,0 +1,172 @@ +@import "../../style/themes/default"; + +@alert-prefix-cls: ~"@{ant-prefix}-alert"; + +@alert-message-color: @heading-color; +@alert-text-color: @text-color; + +.@{alert-prefix-cls} { + position: relative; + padding: 8px 48px 8px 38px; + border-radius: @border-radius-base; + color: @alert-text-color; + font-size: @font-size-base; + line-height: @line-height-base; + + &&-no-icon { + padding: 8px 48px 8px 16px; + } + + &-icon { + font-size: @font-size-lg; + top: 8px + @font-size-base * @line-height-base / 2 - @font-size-lg / 2; + left: 16px; + position: absolute; + } + + &-description { + font-size: @font-size-base; + line-height: 21px; + display: none; + } + + &-success { + border: @border-width-base @border-style-base @green-2; + background-color: @green-1; + .@{alert-prefix-cls}-icon { + color: @success-color; + } + } + + &-info { + border: @border-width-base @border-style-base @primary-2; + background-color: @primary-1; + .@{alert-prefix-cls}-icon { + color: @info-color; + } + } + + &-warning { + border: @border-width-base @border-style-base @yellow-2; + background-color: @yellow-1; + .@{alert-prefix-cls}-icon { + color: @warning-color; + } + } + + &-error { + border: @border-width-base @border-style-base @red-2; + background-color: @red-1; + .@{alert-prefix-cls}-icon { + color: @error-color; + } + } + + &-close-icon { + font-size: @font-size-base; + position: absolute; + right: 16px; + top: 10px; + height: 12px; + line-height: 12px; + overflow: hidden; + cursor: pointer; + + .@{iconfont-css-prefix}-cross { + color: @text-color-secondary; + transition: color .3s ease; + &:hover { + color: #404040; + } + } + } + + &-close-text { + position: absolute; + right: 16px; + } + + &-with-description { + padding: 16px 16px 16px 60px; + position: relative; + border-radius: @border-radius-base; + color: @text-color; + line-height: 1.5; + } + + &-with-description&-no-icon { + padding: 16px; + } + + &-with-description &-icon { + position: absolute; + top: 16px; + left: 20px; + font-size: 24px; + } + + &-with-description &-close-icon { + position: absolute; + top: 16px; + right: 16px; + cursor: pointer; + font-size: @font-size-base; + } + + &-with-description &-message { + font-size: @font-size-lg; + color: @alert-message-color; + display: block; + margin-bottom: 4px; + } + + &-with-description &-description { + display: block; + } + + &&-close { + height: 0 !important; + margin: 0; + padding-top: 0; + padding-bottom: 0; + transition: all .3s @ease-in-out-circ; + transform-origin: 50% 0; + } + + &-slide-up-leave { + animation: antAlertSlideUpOut .3s @ease-in-out-circ; + animation-fill-mode: both; + } + + &-banner { + border-radius: 0; + border: 0; + margin-bottom: 0; + } +} + +@keyframes antAlertSlideUpIn { + 0% { + opacity: 0; + transform-origin: 0% 0%; + transform: scaleY(0); + } + 100% { + opacity: 1; + transform-origin: 0% 0%; + transform: scaleY(1); + } +} + +@keyframes antAlertSlideUpOut { + 0% { + opacity: 1; + transform-origin: 0% 0%; + transform: scaleY(1); + } + 100% { + opacity: 0; + transform-origin: 0% 0%; + transform: scaleY(0); + } +} diff --git a/src/components/alert/style/patch.less b/src/components/alert/style/patch.less new file mode 100644 index 00000000000..1bd7be6dc7a --- /dev/null +++ b/src/components/alert/style/patch.less @@ -0,0 +1,3 @@ +nz-alert { + display: block; +} diff --git a/src/components/badge/nz-badge.component.ts b/src/components/badge/nz-badge.component.ts new file mode 100644 index 00000000000..c7afa16a49d --- /dev/null +++ b/src/components/badge/nz-badge.component.ts @@ -0,0 +1,93 @@ +import { + Component, + OnInit, + ViewEncapsulation, + Input, + HostBinding, + ContentChild, + TemplateRef +} from '@angular/core'; + +import { + trigger, + style, + transition, + animate +} from '@angular/animations'; + +@Component({ + selector : 'nz-badge', + encapsulation: ViewEncapsulation.None, + animations : [ + trigger('enterLeave', [ + transition('void => *', [ + style({ opacity: 0 }), + animate('0.3s cubic-bezier(0.12, 0.4, 0.29, 1.46)') + ]), + transition('* => void', [ + style({ opacity: 1 }), + animate('0.3s cubic-bezier(0.12, 0.4, 0.29, 1.46)') + ]) + ]) + ], + template : `{{nzText}}

{{p}}

{{nzOverflowCount}}+
`, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) +export class NzBadgeComponent implements OnInit { + _showZero = false; + count: number; + maxNumberArray; + countArray = []; + countSingleArray = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]; + @ContentChild('content') content: TemplateRef; + @HostBinding('class.ant-badge') true; + + @HostBinding('class.ant-badge-not-a-wrapper') + get setNoWrapper() { + return !this.content; + } + + @Input() nzOverflowCount = 99; + + @Input() + set nzShowZero(value: boolean | string) { + if (value === '') { + this._showZero = true; + } else { + this._showZero = value as boolean; + } + } + + get nzShowZero() { + return this._showZero; + } + + @Input() nzDot = false; + @Input() nzText: string; + @Input() nzStyle; + @Input() @HostBinding('class.ant-badge-status') nzStatus: string; + + @Input() + set nzCount(value) { + if (value < 0) { + this.count = 0; + } else { + this.count = value; + } + this.countArray = this.count.toString().split(''); + } + + get nzCount() { + return this.count; + } + + constructor() { + } + + ngOnInit() { + this.maxNumberArray = this.nzOverflowCount.toString().split(''); + } +} diff --git a/src/components/badge/nz-badge.module.ts b/src/components/badge/nz-badge.module.ts new file mode 100644 index 00000000000..63e96176a94 --- /dev/null +++ b/src/components/badge/nz-badge.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { NzBadgeComponent } from './nz-badge.component'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + declarations: [ NzBadgeComponent ], + exports : [ NzBadgeComponent ], + imports : [ CommonModule ] +}) +export class NzBadgeModule { +} + diff --git a/src/components/badge/style/index.less b/src/components/badge/style/index.less new file mode 100755 index 00000000000..54cc783caac --- /dev/null +++ b/src/components/badge/style/index.less @@ -0,0 +1,151 @@ +@import "../../style/themes/default"; + +@badge-prefix-cls: ~"@{ant-prefix}-badge"; +@number-prefix-cls: ~"@{ant-prefix}-scroll-number"; + +.@{badge-prefix-cls} { + position: relative; + display: inline-block; + line-height: 1; + vertical-align: middle; + + &-count { + position: absolute; + transform: translateX(-50%); + top: -@badge-height / 2; + height: @badge-height; + border-radius: @badge-height / 2; + min-width: @badge-height; + background: @highlight-color; + color: #fff; + line-height: @badge-height; + text-align: center; + padding: 0 6px; + font-size: @badge-font-size; + white-space: nowrap; + transform-origin: -10% center; + font-family: tahoma; + a, + a:hover { + color: #fff; + } + } + + &-dot { + position: absolute; + transform: translateX(-50%); + transform-origin: 0 center; + top: -@badge-dot-size / 2; + height: @badge-dot-size; + width: @badge-dot-size; + border-radius: 100%; + background: @highlight-color; + z-index: 10; + box-shadow: 0 0 0 1px #fff; + } + + &-status { + line-height: inherit; + vertical-align: baseline; + + &-dot { + width: 8px; + height: 8px; + display: inline-block; + border-radius: 50%; + } + &-success { + background-color: @success-color; + } + &-processing { + background-color: @primary-color; + position: relative; + &:after { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + border: 1px solid @primary-color; + content: ''; + animation: antStatusProcessing 1.2s infinite ease-in-out; + } + } + &-default { + background-color: @normal-color; + } + &-error { + background-color: @error-color; + } + &-warning { + background-color: @warning-color; + } + &-text { + color: @text-color; + font-size: @badge-font-size; + margin-left: 8px; + } + } + + &-zoom-appear, + &-zoom-enter { + animation: antZoomBadgeIn .3s @ease-out-back; + animation-fill-mode: both; + } + + &-zoom-leave { + animation: antZoomBadgeOut .3s @ease-in-back; + animation-fill-mode: both; + } + + &-not-a-wrapper &-count { + top: auto; + display: block; + position: relative; + transform: none !important; + } +} + +@keyframes antStatusProcessing { + 0% { + transform: scale(0.8); + opacity: 0.5; + } + 100% { + transform: scale(2.4); + opacity: 0; + } +} + +.@{number-prefix-cls} { + overflow: hidden; + &-only { + display: inline-block; + transition: all .3s @ease-in-out; + height: @badge-height; + > p { + height: @badge-height; + } + } +} + +@keyframes antZoomBadgeIn { + 0% { + opacity: 0; + transform: scale(0) translateX(-50%); + } + 100% { + transform: scale(1) translateX(-50%); + } +} + +@keyframes antZoomBadgeOut { + 0% { + transform: scale(1) translateX(-50%); + } + 100% { + opacity: 0; + transform: scale(0) translateX(-50%); + } +} diff --git a/src/components/badge/style/patch.less b/src/components/badge/style/patch.less new file mode 100644 index 00000000000..78478606eb3 --- /dev/null +++ b/src/components/badge/style/patch.less @@ -0,0 +1,16 @@ +@import "./index"; + +.@{badge-prefix-cls} { + + &-count { + position: absolute; + transform: translateX(50%); + right: 0; + } + &-dot { + position: absolute; + transform: translateX(50%); + right: 0; + + } +} diff --git a/src/components/breadcrumb/nz-breadcrumb-item.component.ts b/src/components/breadcrumb/nz-breadcrumb-item.component.ts new file mode 100755 index 00000000000..a73c935676d --- /dev/null +++ b/src/components/breadcrumb/nz-breadcrumb-item.component.ts @@ -0,0 +1,18 @@ +import { + Component, +} from '@angular/core'; +import { NzBreadCrumbComponent } from './nz-breadcrumb.component'; + +@Component({ + selector: 'nz-breadcrumb-item', + template: ` + + + + {{nzBreadCrumbComponent?.nzSeparator}}` +}) +export class NzBreadCrumbItemComponent { + constructor(public nzBreadCrumbComponent: NzBreadCrumbComponent) { + } + +} diff --git a/src/components/breadcrumb/nz-breadcrumb.component.spec.ts b/src/components/breadcrumb/nz-breadcrumb.component.spec.ts new file mode 100644 index 00000000000..7bfa2e80bb9 --- /dev/null +++ b/src/components/breadcrumb/nz-breadcrumb.component.spec.ts @@ -0,0 +1,157 @@ +/* tslint:disable:no-unused-variable */ +import {async, ComponentFixture, TestBed, ComponentFixtureAutoDetect} from '@angular/core/testing'; +import {Component, DebugElement} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {NzBreadCrumbModule} from './nz-breadcrumb.module'; +import {NzBreadCrumbComponent} from './nz-breadcrumb.component'; +import {NzBreadCrumbItemComponent} from './nz-breadcrumb-item.component'; + +describe('NzBreadCrumb', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NzBreadCrumbModule], + declarations: [WithoutBreadCrumb, WithoutBreadCrumbItem, TestBreadCrumb, TestSeparator], + providers: [] + }).compileComponents(); + })); + describe('for BreadCrumb', () => { + // it('should throw error if BreadCrumb is not defined', () => { + // const fixture = TestBed.createComponent(WithoutBreadCrumb); + // expect(() => fixture.detectChanges()).not.toThrow(); + // }); + + it('should apply class if BreadCrumb is defined', () => { + const fixture = TestBed.createComponent(TestBreadCrumb); + const testComponent = fixture.debugElement.componentInstance; + const debugElement = fixture.debugElement.query(By.directive(NzBreadCrumbComponent)); + + testComponent._custormString = 'Home'; + fixture.detectChanges(); + expect(debugElement.nativeElement.classList.contains('ant-breadcrumb')).toBe(true); + + testComponent._custormString = ''; + expect(() => { + fixture.detectChanges() + }).not.toThrow(); + + testComponent._custormString = null; + expect(() => { + fixture.detectChanges() + }).not.toThrow(); + }); + it('should should not clear previous defined classes', () => { + const fixture = TestBed.createComponent(TestBreadCrumb); + const testComponent = fixture.debugElement.componentInstance; + const debugElement = fixture.debugElement.query(By.directive(NzBreadCrumbComponent)); + + debugElement.nativeElement.classList.add('custom-class'); + + testComponent._custormString = 'Home'; + fixture.detectChanges(); + expect(debugElement.nativeElement.classList.contains('ant-breadcrumb')).toBe(true); + expect(debugElement.nativeElement.classList.contains('custom-class')).toBe(true); + + testComponent._custormString = ''; + expect(() => { + fixture.detectChanges() + }).not.toThrow(); + + testComponent._custormString = null; + expect(() => { + fixture.detectChanges() + }).not.toThrow(); + }); + + it('should apply class based on separator attribute ', () => { + const fixture = TestBed.createComponent(TestSeparator); + const testComponent = fixture.debugElement.componentInstance; + const debugElement = fixture.debugElement.query(By.directive(NzBreadCrumbComponent)); + + testComponent._separator = '>'; + fixture.detectChanges(); + expect(debugElement.nativeElement.querySelector('.ant-breadcrumb-separator')).toBeDefined(); + expect(debugElement.nativeElement.querySelector('.ant-breadcrumb-separator').innerHTML).toEqual('>'); + + testComponent._separator = ''; + fixture.detectChanges(); + expect(debugElement.nativeElement.querySelector('.ant-breadcrumb-separator')).toBeDefined(); + + testComponent._separator = 'custorm_string'; + expect(() => { + fixture.detectChanges() + }).not.toThrow(); + }) + }) + describe('for BreadCrumbItem', () => { + // it('should throw error if BreadCrumbItem is not defined', () => { + // const fixture = TestBed.createComponent(WithoutBreadCrumbItem); + // expect(() => fixture.detectChanges()).not.toThrow(); + // }); + it('should Custom text content', () => { + const fixture = TestBed.createComponent(TestBreadCrumb); + const testComponent = fixture.debugElement.componentInstance; + const debugElement = fixture.debugElement.query(By.directive(NzBreadCrumbItemComponent)); + + testComponent._custormString = 'Home2'; + fixture.detectChanges(); + expect(debugElement.nativeElement.querySelector('.ant-breadcrumb-link')).toBeDefined(); + expect(debugElement.nativeElement.querySelector('.ant-breadcrumb-separator')).toBeDefined(); + + testComponent._custormString = 'custom text content'; + expect(() => { + fixture.detectChanges() + }).not.toThrow(); + }); + }) +}); +@Component({ + selector: 'test-without-breadcrumb-item', + template: ` + + `, +}) +class WithoutBreadCrumbItem { +} + +@Component({ + selector: 'test-without-breadcrumb', + template: ` + + Home + + `, +}) +class WithoutBreadCrumb { +} +@Component({ + selector: 'test-breadcrumb', + template: ` + + + {{_custormString}} + + + {{_custormString}} + + + ` +}) +class TestBreadCrumb { + _custormString = 'Home'; +} +@Component({ + selector: 'test-separator', + template: ` + + + Home + + + Home2 + + + ` +}) +class TestSeparator { + _separator = '>'; +} diff --git a/src/components/breadcrumb/nz-breadcrumb.component.ts b/src/components/breadcrumb/nz-breadcrumb.component.ts new file mode 100755 index 00000000000..01640e14679 --- /dev/null +++ b/src/components/breadcrumb/nz-breadcrumb.component.ts @@ -0,0 +1,25 @@ +import { + Component, + HostBinding, + Input, + ViewEncapsulation +} from '@angular/core'; + +@Component({ + selector : 'nz-breadcrumb', + encapsulation: ViewEncapsulation.None, + template : ` + `, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) +export class NzBreadCrumbComponent { + @Input() nzSeparator = '/'; + @HostBinding('class.ant-breadcrumb') true; + + constructor() { + } + +} diff --git a/src/components/breadcrumb/nz-breadcrumb.module.ts b/src/components/breadcrumb/nz-breadcrumb.module.ts new file mode 100644 index 00000000000..4e8cd04897c --- /dev/null +++ b/src/components/breadcrumb/nz-breadcrumb.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { NzBreadCrumbComponent } from './nz-breadcrumb.component'; +import { NzBreadCrumbItemComponent } from './nz-breadcrumb-item.component'; + +@NgModule({ + imports : [ CommonModule ], + declarations: [ NzBreadCrumbComponent, NzBreadCrumbItemComponent ], + exports : [ NzBreadCrumbComponent, NzBreadCrumbItemComponent ] +}) +export class NzBreadCrumbModule { +} diff --git a/src/components/breadcrumb/style/index.less b/src/components/breadcrumb/style/index.less new file mode 100755 index 00000000000..357e943923c --- /dev/null +++ b/src/components/breadcrumb/style/index.less @@ -0,0 +1,36 @@ +@import "../../style/themes/default"; + +@breadcrumb-prefix-cls: ~"@{ant-prefix}-breadcrumb"; + +.@{breadcrumb-prefix-cls} { + color: @text-color; + font-size: @font-size-base; + + a { + color: @text-color; + transition: color .3s; + &:hover { + color: @primary-5; + } + } + + & > span:last-child { + font-weight: bold; + color: @text-color; + } + + & > span:last-child &-separator { + display: none; + } + + &-separator { + margin: 0 8px; + color: rgba(0, 0, 0, 0.3); + } + + &-link { + > .@{iconfont-css-prefix} + span { + margin-left: 4px; + } + } +} diff --git a/src/components/breadcrumb/style/patch.less b/src/components/breadcrumb/style/patch.less new file mode 100644 index 00000000000..c55a06d76d5 --- /dev/null +++ b/src/components/breadcrumb/style/patch.less @@ -0,0 +1,12 @@ +@import "./index"; +.@{breadcrumb-prefix-cls} { + display: block; + & > nz-breadcrumb-item:last-child { + font-weight: bold; + color: @text-color; + } + + & > nz-breadcrumb-item:last-child &-separator { + display: none; + } +} diff --git a/src/components/button/nz-button-group.component.ts b/src/components/button/nz-button-group.component.ts new file mode 100644 index 00000000000..68859b7ceaf --- /dev/null +++ b/src/components/button/nz-button-group.component.ts @@ -0,0 +1,48 @@ +import { Component, Input, ViewEncapsulation, AfterContentInit, ElementRef, ViewChild } from '@angular/core'; + +export type NzButtonGroupSize = 'small' | 'large' | 'default' ; + +@Component({ + selector : 'nz-button-group', + encapsulation: ViewEncapsulation.None, + template : ` +
+ +
+ `, + styleUrls : [] +}) +export class NzButtonGroupComponent implements AfterContentInit { + _size: NzButtonGroupSize; + _prefixCls = 'ant-btn-group'; + _sizeMap = { large: 'lg', small: 'sm' }; + @ViewChild('groupWrapper') _groupWrapper: ElementRef; + + @Input() + get nzSize(): NzButtonGroupSize { + return this._size; + }; + + set nzSize(value: NzButtonGroupSize) { + this._size = value; + } + + get _classMap() { + return { + [this._prefixCls] : true, + [`${this._prefixCls}-${this._sizeMap[ this.nzSize ]}`]: this._sizeMap[ this.nzSize ] + } + }; + + constructor() { + } + + ngAfterContentInit() { + /** trim text node between button */ + Array.from(this._groupWrapper.nativeElement.childNodes).forEach((node: HTMLElement) => { + if (node.nodeType === 3) { + this._groupWrapper.nativeElement.removeChild(node); + } + }) + } +} diff --git a/src/components/button/nz-button.component.spec.ts b/src/components/button/nz-button.component.spec.ts new file mode 100644 index 00000000000..d07fb3ce65d --- /dev/null +++ b/src/components/button/nz-button.component.spec.ts @@ -0,0 +1,264 @@ +/* tslint:disable:no-unused-variable */ +import { async, ComponentFixture, TestBed, ComponentFixtureAutoDetect } from '@angular/core/testing'; +import { Component, DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { NzButtonModule } from './nz-button.module'; +import { NzButtonComponent } from './nz-button.component'; +import { NzButtonGroupComponent } from './nz-button-group.component'; + +describe('NzButton', () => { + let testComponent; + let fixture; + let buttonDebugElement; + let fixtureGroup: ComponentFixture; + let groupDebugElement: DebugElement; + let groupInstance: NzButtonGroupComponent; + let testComponentGroup: TestAppGroup; + describe('NzButton without disabled', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports : [ NzButtonModule ], + declarations: [ TestApp ], + providers : [] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestApp); + testComponent = fixture.debugElement.componentInstance; + buttonDebugElement = fixture.debugElement.query(By.css('button')); + }); + + it('should apply class based on type attribute', () => { + testComponent.type = 'primary'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-primary')).toBe(true); + + testComponent.type = 'dashed'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-dashed')).toBe(true); + + testComponent.type = 'danger'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-danger')).toBe(true); + }); + + it('should apply class based on shape attribute', () => { + testComponent.shape = 'circle'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-circle')).toBe(true); + + testComponent.shape = null; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-circle')).toBe(false); + }); + + it('should apply class based on size attribute', () => { + testComponent.size = 'small'; // | 'large' | 'default' + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-sm')).toBe(true); + + testComponent.size = 'large'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-lg')).toBe(true); + + testComponent.size = 'default'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-lg')).toBe(false); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-sm')).toBe(false); + + testComponent.size = null; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-lg')).toBe(false); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-sm')).toBe(false); + }); + + it('should apply class based on ghost attribute', () => { + testComponent.isGhost = true; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-background-ghost')).toBe(true); + + testComponent.isGhost = false; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-background-ghost')).toBe(false); + }); + + it('should should not clear previous defined classes', () => { + buttonDebugElement.nativeElement.classList.add('custom-class'); + + testComponent.type = 'primary'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-primary')).toBe(true); + expect(buttonDebugElement.nativeElement.classList.contains('custom-class')).toBe(true); + + testComponent.type = 'dashed'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-dashed')).toBe(true); + expect(buttonDebugElement.nativeElement.classList.contains('custom-class')).toBe(true); + + testComponent.type = 'danger'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-danger')).toBe(true); + expect(buttonDebugElement.nativeElement.classList.contains('custom-class')).toBe(true); + + testComponent.shape = 'circle'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-circle')).toBe(true); + expect(buttonDebugElement.nativeElement.classList.contains('custom-class')).toBe(true); + + testComponent.size = 'small'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-sm')).toBe(true); + expect(buttonDebugElement.nativeElement.classList.contains('custom-class')).toBe(true); + + testComponent.size = 'large'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-lg')).toBe(true); + expect(buttonDebugElement.nativeElement.classList.contains('custom-class')).toBe(true); + + testComponent.size = 'default'; + fixture.detectChanges(); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-lg')).toBe(false); + expect(buttonDebugElement.nativeElement.classList.contains('ant-btn-sm')).toBe(false); + expect(buttonDebugElement.nativeElement.classList.contains('custom-class')).toBe(true); + }); + + it('should handle a click on the button', () => { + buttonDebugElement.nativeElement.click(); + expect(testComponent.isLoading).toBe(true); + setTimeout(_ => { + expect(testComponent.isLoading).toBe(false); + }, 5000); + }); + }); + + describe('NzButton with disabled', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports : [ NzButtonModule ], + declarations: [ TestAppDisabled ], + providers : [] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestAppDisabled); + testComponent = fixture.debugElement.componentInstance; + buttonDebugElement = fixture.debugElement.query(By.css('button')); + }); + + it('should not increment if disabled', () => { + buttonDebugElement.nativeElement.click(); + expect(testComponent.isLoading).toBe(false); + }); + }); + + describe('NzButton with group', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports : [ NzButtonModule ], + declarations: [ TestAppGroup ], + providers : [] + }).compileComponents(); + })); + + beforeEach(() => { + fixtureGroup = TestBed.createComponent(TestAppGroup); + testComponentGroup = fixtureGroup.debugElement.componentInstance; + groupDebugElement = fixtureGroup.debugElement.query(By.directive(NzButtonGroupComponent)); + }); + + it('should apply class based on size attribute', () => { + groupInstance = groupDebugElement.injector.get(NzButtonGroupComponent); + testComponentGroup.size = 'large'; + fixtureGroup.detectChanges(); + expect(groupDebugElement.nativeElement.firstElementChild.classList.contains('ant-btn-group-lg')).toBe(true); + + testComponentGroup.size = 'small'; + fixtureGroup.detectChanges(); + expect(groupDebugElement.nativeElement.firstElementChild.classList.contains('ant-btn-group-sm')).toBe(true); + + testComponentGroup.size = 'custom-string'; + fixtureGroup.detectChanges(); + expect(groupDebugElement.nativeElement.firstElementChild.classList.contains('ant-btn-group-lg')).toBe(false); + expect(groupDebugElement.nativeElement.firstElementChild.classList.contains('ant-btn-group-sm')).toBe(false); + }); + }); + +}); + +/** Test component that contains an nzButton. */ +@Component({ + selector: 'test-app', + template: ` + + + + + + +
+ +
+ ` +}) +class TestApp { + type = 'primary'; + size = 'default'; + shape = 'circle'; + isLoading = false; + isGhost = false; + + clickButton = (value) => { + this.isLoading = true; + setTimeout(_ => { + this.isLoading = false; + }, 5000); + }; +} + + +@Component({ + selector: 'test-app-disabled', + template: ` + + ` +}) +class TestAppDisabled { + type = 'primary'; + isLoading = false; + + + clickButton = (value) => { + this.isLoading = true; + setTimeout(_ => { + this.isLoading = false; + }, 5000); + }; +} + + +@Component({ + selector: 'test-app-group', + template: ` + + + + + ` +}) +class TestAppGroup { + size = 'small'; +} + + diff --git a/src/components/button/nz-button.component.ts b/src/components/button/nz-button.component.ts new file mode 100644 index 00000000000..5ea27413d77 --- /dev/null +++ b/src/components/button/nz-button.component.ts @@ -0,0 +1,152 @@ +import { + Component, + ViewEncapsulation, + Input, + ElementRef, + HostListener, + AfterContentInit, + Renderer2 +} from '@angular/core'; + +export type NzButtonType = 'primary' | 'dashed' | 'danger'; +export type NzButtonShape = 'circle' | null ; +export type NzButtonSize = 'small' | 'large' | 'default' ; + +@Component({ + selector : '[nz-button]', + encapsulation: ViewEncapsulation.None, + template : ` + + + `, + styleUrls : [ + './style/index.less' + ] +}) +export class NzButtonComponent implements AfterContentInit { + _el: HTMLElement; + nativeElement: HTMLElement; + _iconElement: HTMLElement; + _type: NzButtonType; + _shape: NzButtonShape; + _size: NzButtonSize; + _classList: Array = []; + _iconOnly = false; + _loading = false; + _clicked = false; + _ghost = false; + _prefixCls = 'ant-btn'; + _sizeMap = { large: 'lg', small: 'sm' }; + + @Input() + set nzGhost(value: boolean) { + this._ghost = value; + this._setClassMap(); + } + + get nzGhost(): boolean { + return this._ghost; + } + + @Input() + get nzType(): NzButtonType { + return this._type; + }; + + set nzType(value: NzButtonType) { + this._type = value; + this._setClassMap(); + } + + @Input() + get nzShape(): NzButtonShape { + return this._shape; + }; + + set nzShape(value: NzButtonShape) { + this._shape = value; + this._setClassMap(); + } + + @Input() + set nzSize(value: NzButtonSize) { + this._size = value; + this._setClassMap(); + } + + get nzSize(): NzButtonSize { + return this._size; + }; + + @Input() + set nzLoading(value: boolean) { + this._loading = value; + this._setClassMap(); + this._setIconDisplay(value); + } + + get nzLoading(): boolean { + return this._loading; + }; + + /** toggle button clicked animation */ + @HostListener('click') + _onClick() { + this._clicked = true; + this._setClassMap(); + setTimeout(() => { + this._clicked = false; + this._setClassMap(); + }, 300); + }; + + + _setIconDisplay(value: boolean) { + const innerI = this._iconElement; + if (innerI) { + this._renderer.setStyle(innerI, 'display', value ? 'none' : 'inline-block'); + } + } + + + /** temp solution since no method add classMap to host https://github.com/angular/angular/issues/7289 */ + _setClassMap(): void { + this._classList.forEach(_className => { + this._renderer.removeClass(this._el, _className); + }) + this._classList = [ + this.nzType && `${this._prefixCls}-${this.nzType}`, + this.nzShape && `${this._prefixCls}-${this.nzShape}`, + this._sizeMap[ this.nzSize ] && `${this._prefixCls}-${this._sizeMap[ this.nzSize ]}`, + this.nzLoading && `${this._prefixCls}-loading`, + this._clicked && `${this._prefixCls}-clicked`, + this._iconOnly && `${this._prefixCls}-icon-only`, + this.nzGhost && `${this._prefixCls}-background-ghost`, + ].filter((item) => { + return !!item; + }); + this._classList.forEach(_className => { + this._renderer.addClass(this._el, _className); + }) + } + + constructor(private _elementRef: ElementRef, private _renderer: Renderer2) { + this._el = this._elementRef.nativeElement; + this.nativeElement = this._elementRef.nativeElement; + this._renderer.addClass(this._el, this._prefixCls); + } + + ngAfterContentInit() { + this._iconElement = this._innerIElement; + /** check if host children only has i element */ + if (this._iconElement && this._el.children.length === 1 && (this._iconElement.isEqualNode(this._el.children[ 0 ]))) { + this._iconOnly = true; + this._setClassMap(); + } + this._setIconDisplay(this.nzLoading); + } + + get _innerIElement() { + return this._el.querySelector('i'); + } +} diff --git a/src/components/button/nz-button.module.ts b/src/components/button/nz-button.module.ts new file mode 100644 index 00000000000..5c10abafc12 --- /dev/null +++ b/src/components/button/nz-button.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { NzButtonGroupComponent } from './nz-button-group.component'; +import { NzButtonComponent } from './nz-button.component'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + declarations: [ NzButtonComponent, NzButtonGroupComponent ], + exports : [ NzButtonComponent, NzButtonGroupComponent ], + imports : [ CommonModule ] +}) + +export class NzButtonModule { +} diff --git a/src/components/button/style/index.less b/src/components/button/style/index.less new file mode 100755 index 00000000000..7e0e139e16b --- /dev/null +++ b/src/components/button/style/index.less @@ -0,0 +1,168 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; +@import "./mixin"; + +@btn-prefix-cls: ~"@{ant-prefix}-btn"; + +// for compatibile +@btn-ghost-color: @text-color; +@btn-ghost-bg: transparent; +@btn-ghost-border: @border-color-base; + +// Button styles +// ----------------------------- +.@{btn-prefix-cls} { + .btn; + .btn-default; + + &-primary { + .btn-primary; + + .@{btn-prefix-cls}-group &:not(:first-child):not(:last-child) { + border-right-color: @btn-group-border; + border-left-color: @btn-group-border; + + &:disabled { + border-color: @btn-default-border; + } + } + + .@{btn-prefix-cls}-group &:first-child { + &:not(:last-child) { + border-right-color: @btn-group-border; + &[disabled] { + border-right-color: @btn-default-border; + } + } + } + + .@{btn-prefix-cls}-group &:last-child:not(:first-child), + .@{btn-prefix-cls}-group & + & { + border-left-color: @btn-group-border; + &[disabled] { + border-left-color: @btn-default-border; + } + } + } + + &-ghost { + .btn-ghost; + } + + &-dashed { + .btn-dashed; + } + + &-danger { + .btn-danger; + } + + &-circle, + &-circle-outline { + .btn-circle(@btn-prefix-cls); + } + + &:before { + position: absolute; + top: -1px; + left: -1px; + bottom: -1px; + right: -1px; + background: #fff; + opacity: 0.35; + content: ''; + border-radius: inherit; + z-index: 1; + transition: opacity .2s; + pointer-events: none; + display: none; + } + + .@{iconfont-css-prefix} { + transition: margin-left .3s @ease-in-out; + } + + &&-loading:before { + display: block; + } + + &&-loading:not(&-circle):not(&-circle-outline) { + padding-left: 29px; + pointer-events: none; + position: relative; + .@{iconfont-css-prefix} { + margin-left: -14px; + } + } + + &-sm&-loading:not(&-circle):not(&-circle-outline) { + padding-left: 24px; + .@{iconfont-css-prefix} { + margin-left: -17px; + } + } + + &-group { + .btn-group(@btn-prefix-cls); + } + + &:not(&-circle):not(&-circle-outline)&-icon-only { + padding-left: 8px; + padding-right: 8px; + } + + // http://stackoverflow.com/a/21281554/3040605 + &:focus > span, + &:active > span { + position: relative; + } + + // To ensure that a space will be placed between character and `Icon`. + > .@{iconfont-css-prefix} + span, + > span + .@{iconfont-css-prefix} { + margin-left: 0.5em; + } + + &-clicked:after { + content: ''; + position: absolute; + top: -1px; + left: -1px; + bottom: -1px; + right: -1px; + border-radius: inherit; + border: 0 solid @primary-color; + opacity: 0.4; + animation: buttonEffect .4s; + display: block; + } + + &-danger&-clicked:after { + border-color: @btn-danger-color; + } + + &-background-ghost { + background: transparent !important; + border-color: #fff; + color: #fff; + } + + &-background-ghost&-primary { + .button-variant-ghost(@primary-color); + } + + &-background-ghost&-danger { + .button-variant-ghost(@btn-danger-color); + } +} + +@keyframes buttonEffect { + to { + opacity: 0; + top: -6px; + left: -6px; + bottom: -6px; + right: -6px; + border-width: 6px; + } +} diff --git a/src/components/button/style/mixin.less b/src/components/button/style/mixin.less new file mode 100755 index 00000000000..44b50247feb --- /dev/null +++ b/src/components/button/style/mixin.less @@ -0,0 +1,294 @@ +// mixins for button +// ------------------------ +.button-size(@height; @padding; @font-size; @border-radius) { + padding: @padding; + font-size: @font-size; + border-radius: @border-radius; + height: @height; +} + +.button-disabled() { + &.disabled, + &[disabled] { + &, + &:hover, + &:focus, + &:active, + &.active { + .button-color(@btn-disable-color; @btn-disable-bg; @btn-disable-border); + } + } +} + +.button-variant-primary(@color; @background) { + .button-color(@color; @background; @background); + &:hover, + &:focus { + .button-color(@color; ~`colorPalette("@{background}", 5)`; ~`colorPalette("@{background}", 5)`); + } + + &:active, + &.active { + .button-color(@color; ~`colorPalette("@{background}", 7)`; ~`colorPalette("@{background}", 7)`); + } + + .button-disabled(); +} + +.button-variant-other(@color; @background; @border) { + .button-color(@color; @background; @border); + + &:hover, + &:focus { + .button-color(@primary-color; @background; @primary-color); + } + + &:active, + &.active { + .button-color(@primary-7; @background; @primary-7); + } + + .button-disabled(); +} + +.button-variant-danger(@color; @background; @border) { + .button-color(@color; @background; @border); + + &:hover, + &:focus { + .button-color(@btn-primary-color; @color; @color;); + } + + &:active, + &.active { + .button-color(@btn-primary-color; ~`colorPalette("@{color}", 7)`; ~`colorPalette("@{color}", 7)`;); + } + + .button-disabled(); +} + +.button-variant-ghost(@color) { + .button-color(@color; transparent; @color); + + &:hover, + &:focus { + .button-color(~`colorPalette("@{color}", 5)`; transparent; ~`colorPalette("@{color}", 5)`); + } + + &:active, + &.active { + .button-color(~`colorPalette("@{color}", 7)`; transparent; ~`colorPalette("@{color}", 7)`); + } + + .button-disabled(); +} + +.button-color(@color; @background; @border) { + color: @color; + background-color: @background; + border-color: @border; + // a inside Button which only work in Chrome + // http://stackoverflow.com/a/17253457 + > a:only-child { + color: currentColor; + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: transparent; + } + } +} + +.button-group-base(@btnClassName) { + position: relative; + display: inline-block; + > .@{btnClassName} { + position: relative; + z-index: 1; + + &:hover, + &:focus, + &:active, + &.active { + z-index: 2; + } + + &:disabled { + z-index: 0; + } + } + + // size + &-lg > .@{btnClassName} { + .button-size(@btn-height-lg; @btn-padding-lg; @btn-font-size-lg; @btn-border-radius-base); + } + + &-sm > .@{btnClassName} { + .button-size(@btn-height-sm; @btn-padding-sm; @font-size-base; @btn-border-radius-sm); + > .@{iconfont-css-prefix} { + font-size: @font-size-base; + } + } +} + +// Base styles of buttons +// -------------------------------------------------- +.btn() { + display: inline-block; + margin-bottom: 0; + font-weight: @btn-font-weight; + text-align: center; + touch-action: manipulation; + cursor: pointer; + background-image: none; + border: @border-width-base @border-style-base transparent; + white-space: nowrap; + line-height: 1.15; // https://github.com/ant-design/ant-design/issues/7070 + .button-size(@btn-height-base; @btn-padding-base; @font-size-base; @btn-border-radius-base); + user-select: none; + transition: all .3s @ease-in-out; + position: relative; + + > .@{iconfont-css-prefix} { + line-height: 1; + } + + &, + &:active, + &:focus { + outline: 0; + } + + &:not([disabled]):hover { + text-decoration: none; + } + + &:not([disabled]):active { + outline: 0; + transition: none; + } + + &.disabled, + &[disabled] { + cursor: not-allowed; + > * { + pointer-events: none; + } + } + + &-lg { + .button-size(@btn-height-lg; @btn-padding-lg; @btn-font-size-lg; @btn-border-radius-base); + } + + &-sm { + .button-size(@btn-height-sm; @btn-padding-sm; @font-size-base; @btn-border-radius-sm); + } +} + +// primary button style +.btn-primary() { + .button-variant-primary(@btn-primary-color; @btn-primary-bg); +} + +// default button style +.btn-default() { + .button-variant-other(@btn-default-color; @btn-default-bg; @btn-default-border); + &:hover, + &:focus, + &:active, + &.active { + background: #fff; + } +} + +// ghost button style +.btn-ghost() { + .button-variant-other(@btn-ghost-color, @btn-ghost-bg, @btn-ghost-border); +} + +// dashed button style +.btn-dashed() { + .button-variant-other(@btn-default-color, @btn-default-bg, @btn-default-border); + border-style: dashed; +} + +// danger button style +.btn-danger() { + .button-variant-danger(@btn-danger-color, @btn-danger-bg, @btn-danger-border); +} + +// circle button: the content only contains icon +.btn-circle(@btnClassName: btn) { + .square(@btn-circle-size); + .button-size(@btn-circle-size; 0; @font-size-base + 2px; 50%); + + &.@{btnClassName}-lg { + .square(@btn-circle-size-lg); + .button-size(@btn-circle-size-lg; 0; @btn-font-size-lg + 2px; 50%); + } + + &.@{btnClassName}-sm { + .square(@btn-circle-size-sm); + .button-size(@btn-circle-size-sm; 0; @font-size-base; 50%); + } +} + +// Horizontal button groups styl +// -------------------------------------------------- +.btn-group(@btnClassName: btn) { + .button-group-base(@btnClassName); + + .@{btnClassName} + .@{btnClassName}, + .@{btnClassName} + &, + & + .@{btnClassName}, + & + & { + margin-left: -1px; + } + + .@{btnClassName}:not(:first-child):not(:last-child) { + border-radius: 0; + padding-left: 8px; + padding-right: 8px; + } + + > .@{btnClassName}:first-child { + margin-left: 0; + &:not(:last-child) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + padding-right: 8px; + } + } + + > .@{btnClassName}:last-child:not(:first-child) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + padding-left: 8px; + } + + & > & { + float: left; + } + + & > &:not(:first-child):not(:last-child) > .@{btnClassName} { + border-radius: 0; + } + + & > &:first-child:not(:last-child) { + > .@{btnClassName}:last-child { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + padding-right: 8px; + } + } + + & > &:last-child:not(:first-child) > .@{btnClassName}:first-child { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + padding-left: 8px; + } +} diff --git a/src/components/calendar/nz-calendar.component.ts b/src/components/calendar/nz-calendar.component.ts new file mode 100644 index 00000000000..603e8b135e0 --- /dev/null +++ b/src/components/calendar/nz-calendar.component.ts @@ -0,0 +1,380 @@ +import { + Component, + OnInit, + ViewEncapsulation, + Input, + ElementRef, + Output, + ContentChild, + TemplateRef, + EventEmitter, HostBinding +} from '@angular/core'; + +import * as moment from 'moment'; +import { Moment } from 'moment'; +import 'moment/locale/zh-cn'; + +export interface MonthInterface { + index: number; + name: string; + isCurrentMonth: boolean; + isSelectedMonth: boolean; +} + +export type QuartersType = Array; + +export interface DayInterface { + number: number; + isLastMonth: boolean; + isNextMonth: boolean; + isCurrentDay: boolean; + isSelectedDay: boolean; + title: string; + date: Moment; + disabled: boolean; + firstDisabled: boolean; + lastDisabled: boolean; +} + +export interface WeekInterface { + days: Array +} + +@Component({ + selector : 'nz-calendar', + encapsulation: ViewEncapsulation.None, + template : ` +
+
+ + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + +
+ {{_min}} +
+
+
{{day.number}}
+
+ + +
+
+
+
{{day.number}}
+
+ + + + + + + + + + + +
+
+
{{month.name}}
+
+ + +
+
+
+
+ {{month.name}} +
+
+
+
+
`, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) +export class NzCalendarComponent implements OnInit { + _el: HTMLElement; + _weeksCalendar: Array = []; + _quartersCalendar: Array = []; + _listOfWeekName: Array = []; + _listOfMonthName: Array = []; + _listOfYearName: Array = []; + _yearUnit = '年'; + _monthUnit = '月'; + _showMonth = moment(new Date()).month(); + _showYear = moment(new Date()).year(); + _value: Date = new Date(); + _locale = 'zh-cn'; + @ContentChild('dateCell') dateCell: TemplateRef; + @ContentChild('monthCell') monthCell: TemplateRef; + + @Output() nzClickDay: EventEmitter = new EventEmitter(); + @Output() nzClickMonth: EventEmitter = new EventEmitter(); + @Input() nzClearTime = true; + @Input() @HostBinding('class.ant-patch-full-height') nzDatePicker = false; + @Input() nzMode = 'year'; + @Input() nzFullScreen = true; + @Input() nzShowHeader = true; + @Input() nzDisabledDate: Function; + + @Input() + get nzValue(): Date { + return this._value || new Date(); + } + + set nzValue(value: Date) { + if (this._value === value) { + return; + } + this._value = value || new Date(); + this._showMonth = moment(this._value).month(); + this._showYear = moment(this._value).year(); + this._buildCalendar(); + } + + @Input() + get nzShowYear() { + return this._showYear; + } + + set nzShowYear(value) { + this._showYear = value; + this._buildCalendar(); + } + + @Input() + get nzShowMonth() { + return this._showMonth; + } + + set nzShowMonth(value) { + this._showMonth = value; + this._buildCalendar(); + } + + @Input() + get nzLocale(): string { + return this._locale; + } + + set nzLocale(value: string) { + this._locale = value; + moment.locale(this._locale); + } + + _removeTime(date) { + if (this.nzClearTime) { + return date.hour(0).minute(0).second(0).millisecond(0); + } else { + return date; + } + }; + + _clickDay($event, day) { + $event.preventDefault(); + $event.stopPropagation(); + if (day.disabled) { + return; + } + this.nzClickDay.emit(day); + }; + + _clickMonth($event, month) { + $event.preventDefault(); + $event.stopPropagation(); + this.nzClickMonth.emit(month); + }; + + _buildMonth(d: Moment): Array { + const weeks: Array = []; + const _rawDate = this._removeTime(d); + const start = _rawDate.clone().date(1).day(0); + const month = _rawDate.clone(); + let done = false; + const date = start.clone(); + let monthIndex = date.month(); + let count = 0; + while (!done) { + weeks.push({ days: this._buildWeek(date.clone(), month) }); + date.add(1, 'w'); + done = count++ > 4; + monthIndex = date.month(); + } + return weeks; + }; + + _buildWeek(date: Moment, month: Moment): Array { + const days: Array = []; + for (let i = 0; i < 7; i++) { + days.push({ + number : date.date(), + isLastMonth : date.month() < month.month(), + isNextMonth : date.month() > month.month(), + isCurrentDay : date.isSame(new Date(), 'day'), + isSelectedDay: date.isSame(this.nzValue, 'day'), + title : date.format('YYYY-MM-DD'), + date : date, + disabled : this.nzDisabledDate && this.nzDisabledDate(date.toDate()), + firstDisabled: this.nzDisabledDate && this.nzDisabledDate(date.toDate()) && (date.day() === 0 || (date.day() !== 0 && this.nzDisabledDate && !this.nzDisabledDate(date.clone().subtract(1, 'day').toDate()))), + lastDisabled : this.nzDisabledDate && this.nzDisabledDate(date.toDate()) && (date.day() === 6 || (date.day() !== 6 && this.nzDisabledDate && !this.nzDisabledDate(date.clone().add(1, 'day').toDate()))) + }); + date = date.clone(); + date.add(1, 'd'); + } + return days; + }; + + _buildYears(date: Moment) { + const quarters = []; + let months: Array = []; + for (let i = 0; i < 12; i++) { + months.push({ + index : i, + name : this._listOfMonthName[ i ], + isCurrentMonth : moment(new Date()).month() === i && date.isSame(new Date(), 'year'), + isSelectedMonth: this._showMonth === i + }); + if ((i + 1) % 3 === 0) { + quarters.push(months); + months = []; + } + } + return quarters; + }; + + _buildCalendar() { + moment.locale(this._locale); + /** TODO replace with real i18n*/ + if (this._locale !== 'zh-cn') { + try { + this._yearUnit = moment.duration(12, 'month').humanize().split(' ')[ 1 ][ 0 ].toUpperCase() + moment.duration(12, 'month').humanize().split(' ')[ 1 ].slice(1, moment.duration(12, 'month').humanize().split(' ')[ 1 ].length); + this._monthUnit = moment.duration(4, 'week').humanize().split(' ')[ 1 ][ 0 ].toUpperCase() + moment.duration(4, 'week').humanize().split(' ')[ 1 ].slice(1, moment.duration(4, 'week').humanize().split(' ')[ 1 ].length); + } catch (e) { + } + } + this._listOfYearName = this._generateYears(this._showYear); + this._listOfWeekName = moment.weekdaysMin(); + this._listOfMonthName = moment.months(); + const date = moment(this.nzValue).year(this._showYear).month(this._showMonth); + this._weeksCalendar = this._buildMonth(date); + this._quartersCalendar = this._buildYears(date); + }; + + _generateYears(year) { + const listOfYears = []; + for (const i of Array.from(Array(20).keys())) { + listOfYears.push(i - 10 + year); + } + return listOfYears; + }; + + constructor(private _elementRef: ElementRef) { + this._el = this._elementRef.nativeElement; + } + + ngOnInit() { + this._buildCalendar(); + } +} diff --git a/src/components/calendar/nz-calendar.module.ts b/src/components/calendar/nz-calendar.module.ts new file mode 100644 index 00000000000..b1025dfd4f3 --- /dev/null +++ b/src/components/calendar/nz-calendar.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { NzCalendarComponent } from './nz-calendar.component'; +import { CommonModule } from '@angular/common'; +import { NzSelectModule } from '../select/nz-select.module'; +import { NzRadioModule } from '../radio/nz-radio.module'; +import { FormsModule } from '@angular/forms'; + +@NgModule({ + imports : [ CommonModule, NzSelectModule, NzRadioModule, FormsModule ], + declarations: [ NzCalendarComponent ], + exports : [ NzCalendarComponent ] +}) + +export class NzCalendarModule { +} + + + diff --git a/src/components/calendar/style/index.less b/src/components/calendar/style/index.less new file mode 100755 index 00000000000..ea3138b049c --- /dev/null +++ b/src/components/calendar/style/index.less @@ -0,0 +1,266 @@ +@import "../../style/themes/default"; + +@full-calendar-prefix-cls: ~"@{ant-prefix}-fullcalendar"; + +.@{full-calendar-prefix-cls} { + font-size: @font-size-base; + line-height: @line-height-base; + outline: none; + border-top: @border-width-base @border-style-base @border-color-base; + + &-month-select { + margin-left: 5px; + } + + &-header { + padding: 11px 16px 11px 0; + text-align: right; + + .@{ant-prefix}-select-dropdown { + text-align: left; + } + + .@{ant-prefix}-radio-group { + margin-left: 8px; + text-align: left; + } + + label.@{ant-prefix}-radio-button { + height: 22px; + line-height: 20px; + padding: 0 10px; + } + } + + &-date-panel { + position: relative; + outline: none; + } + + &-calendar-body { + padding: 8px 8px 14px; + } + + table { + border-collapse: collapse; + max-width: 100%; + background-color: transparent; + width: 100%; + height: 246px; + } + + table, + th, + td { + border: 0; + } + + td { + position: relative; + } + + &-calendar-table { + border-spacing: 0; + margin-bottom: 0; + } + + &-column-header { + line-height: 18px; + padding: 0; + width: 33px; + text-align: center; + .@{full-calendar-prefix-cls}-column-header-inner { + display: block; + font-weight: normal; + } + } + + &-week-number-header { + .@{full-calendar-prefix-cls}-column-header-inner { + display: none; + } + } + + &-month, + &-date { + text-align: center; + transition: all .3s; + } + + &-value { + display: block; + margin: 0 auto; + color: @text-color; + border-radius: @border-radius-base; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + line-height: 22px; + transition: all .3s; + + &:hover { + background: @item-hover-bg; + cursor: pointer; + } + + &:active { + background: @primary-color; + color: #fff; + } + } + + &-month-panel-cell &-value { + width: 48px; + } + + &-today &-value, + &-month-panel-current-cell &-value { + box-shadow: 0 0 0 1px @primary-color; + } + + &-selected-day &-value, + &-month-panel-selected-cell &-value { + background: @primary-color; + color: #fff; + } + + &-disabled-cell-first-of-row &-value { + border-top-left-radius: @border-radius-base; + border-bottom-left-radius: @border-radius-base; + } + + &-disabled-cell-last-of-row &-value { + border-top-right-radius: @border-radius-base; + border-bottom-right-radius: @border-radius-base; + } + + &-last-month-cell &-value, + &-next-month-btn-day &-value { + color: @disabled-color; + } + + &-month-panel-table { + table-layout: fixed; + width: 100%; + border-collapse: separate; + } + + &-content { + position: absolute; + width: 100%; + left: 0; + bottom: -9px; + } + + &-fullscreen { + border-top: 0; + } + + &-fullscreen &-table { + table-layout: fixed; + } + + &-fullscreen &-header { + .@{ant-prefix}-radio-group { + margin-left: 16px; + } + label.@{ant-prefix}-radio-button { + height: @input-height-base; + line-height: @input-height-base - 2px; + } + } + + &-fullscreen &-month, + &-fullscreen &-date { + text-align: left; + margin: 0 4px; + display: block; + color: @text-color; + height: 116px; + padding: 4px 8px; + border-top: 2px solid @border-color-split; + transition: background .3s; + + &:hover { + background: @item-hover-bg; + cursor: pointer; + } + + &:active { + background: @primary-2; + } + } + + &-fullscreen &-column-header { + text-align: right; + padding-right: 12px; + padding-bottom: 5px; + } + + &-fullscreen &-value { + text-align: right; + background: transparent; + width: auto; + } + + &-fullscreen &-today &-value { + color: @text-color; + } + + &-fullscreen &-month-panel-current-cell &-month, + &-fullscreen &-today &-date { + border-top-color: @primary-color; + background: transparent; + } + + &-fullscreen &-month-panel-current-cell &-value, + &-fullscreen &-today &-value { + box-shadow: none; + } + + &-fullscreen &-month-panel-selected-cell &-month, + &-fullscreen &-selected-day &-date { + background: @primary-1; + } + + &-fullscreen &-month-panel-selected-cell &-value, + &-fullscreen &-selected-day &-value { + color: @primary-color; + } + + &-fullscreen &-last-month-cell &-date, + &-fullscreen &-next-month-btn-day &-date { + color: @disabled-color; + } + + &-fullscreen &-content { + height: 90px; + overflow-y: auto; + position: static; + width: auto; + left: auto; + bottom: auto; + } + + &-disabled-cell &-date { + &, + &:hover { + cursor: not-allowed; + } + } + + &-disabled-cell:not(&-today) &-date { + &, + &:hover { + background: transparent; + } + } + + &-disabled-cell &-value { + color: @disabled-color; + border-radius: 0; + width: auto; + cursor: not-allowed; + } +} diff --git a/src/components/calendar/style/patch.less b/src/components/calendar/style/patch.less new file mode 100644 index 00000000000..0760d735209 --- /dev/null +++ b/src/components/calendar/style/patch.less @@ -0,0 +1,8 @@ +nz-calendar { + display: block; + position: relative; +} + +.ant-patch-full-height { + height: 100%; +} diff --git a/src/components/card/nz-card-grid.directive.ts b/src/components/card/nz-card-grid.directive.ts new file mode 100644 index 00000000000..110c20df817 --- /dev/null +++ b/src/components/card/nz-card-grid.directive.ts @@ -0,0 +1,8 @@ +import { Directive, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[nz-card-grid]' +}) +export class NzCardGridDirective { + @HostBinding('class.ant-card-grid') true; +} diff --git a/src/components/card/nz-card.component.ts b/src/components/card/nz-card.component.ts new file mode 100755 index 00000000000..e11c1679349 --- /dev/null +++ b/src/components/card/nz-card.component.ts @@ -0,0 +1,61 @@ +import { + Component, + Input, + HostBinding, + ContentChild, + TemplateRef, + ViewEncapsulation +} from '@angular/core'; + +@Component({ + selector : 'nz-card', + encapsulation: ViewEncapsulation.None, + template : ` +
+

+ + +

+
+
+ + +
+
+ + +
+

+

+ +

+

+ +

+

+ +

+

+ +

+
+
+ `, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) +export class NzCardComponent { + @Input() @HostBinding('class.ant-card-bordered') nzBordered = true; + @Input() nzLoading = false; + @Input() @HostBinding('class.ant-card-no-hovering') nzNoHovering = false; + @ContentChild('title') title: TemplateRef; + @ContentChild('extra') extra: TemplateRef; + @ContentChild('body') body: TemplateRef; + @HostBinding('class.ant-card') true; +} diff --git a/src/components/card/nz-card.module.ts b/src/components/card/nz-card.module.ts new file mode 100644 index 00000000000..a715f0693cf --- /dev/null +++ b/src/components/card/nz-card.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { NzCardComponent } from './nz-card.component'; +import { NzCardGridDirective } from './nz-card-grid.directive'; + +@NgModule({ + imports : [ CommonModule ], + declarations: [ NzCardComponent, NzCardGridDirective ], + exports : [ NzCardComponent, NzCardGridDirective ] +}) +export class NzCardModule { +} diff --git a/src/components/card/style/index.less b/src/components/card/style/index.less new file mode 100755 index 00000000000..80c9cff4020 --- /dev/null +++ b/src/components/card/style/index.less @@ -0,0 +1,124 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; + +@card-prefix-cls: ~"@{ant-prefix}-card"; +@card-padding-base: 24px; +@card-padding-wider: 32px; + +.@{card-prefix-cls} { + background: @component-background; + border-radius: @border-radius-sm; + font-size: @font-size-base; + position: relative; + transition: all .3s; + + &:not(&-no-hovering):hover { + box-shadow: @box-shadow-base; + border-color: transparent; + } + + &-bordered { + border: @border-width-base @border-style-base @border-color-split; + } + + &-head { + height: @card-head-height; + line-height: @card-head-height; + background: @card-head-background; + border-bottom: @border-width-base @border-style-base @border-color-split; + padding: 0 @card-padding-base; + border-radius: @border-radius-sm @border-radius-sm 0 0; + + &-title { + font-size: @font-size-lg; + display: inline-block; + text-overflow: ellipsis; + width: 100%; + overflow: hidden; + white-space: nowrap; + color: @card-head-color; + font-weight: 500; + } + } + + &-extra { + position: absolute; + right: @card-padding-base; + top: 14px; + } + + &-body { + padding: @card-padding-base; + .clearfix; + } + + &-loading &-body { + user-select: none; + padding: 0; + } + + &-loading-content { + padding: @card-padding-base; + } + + &-loading-block { + display: inline-block; + margin: 5px 1% 0; + height: 14px; + border-radius: @border-radius-sm; + background: linear-gradient(90deg, rgba(207, 216, 220, .2), rgba(207, 216, 220, .4), rgba(207, 216, 220, .2)); + animation: card-loading 1.4s ease infinite; + background-size: 600% 600%; + } + + &-contain-grid &-body { + margin: -0.5px; + padding: 0; + } + + &-grid { + border-radius: 0; + border: 0; + box-shadow: 0 0 0 0.5px @border-color-split, 0 0 0 0.5px @border-color-split inset; + width: 33.33%; + float: left; + padding: @card-padding-base; + transition: all .3s; + &:hover { + position: relative; + z-index: 1; + box-shadow: @box-shadow-base; + } + } + + &-wider-padding &-head { + padding: 0 @card-padding-wider; + } + + &-wider-padding &-body { + padding: @card-padding-base @card-padding-wider; + } + + &-wider-padding &-extra { + right: @card-padding-wider; + } + + &-padding-transition &-head, + &-padding-transition &-body { + transition: padding .3s; + } + + &-padding-transition &-extra { + transition: right .3s; + } +} + +@keyframes card-loading { + 0%, + 100% { + background-position: 0 50%; + } + 50% { + background-position: 100% 50%; + } +} diff --git a/src/components/card/style/patch.less b/src/components/card/style/patch.less new file mode 100644 index 00000000000..75b055313b3 --- /dev/null +++ b/src/components/card/style/patch.less @@ -0,0 +1,4 @@ +nz-card { + display: block; + position: relative; +} diff --git a/src/components/carousel/nz-carousel-content.directive.ts b/src/components/carousel/nz-carousel-content.directive.ts new file mode 100755 index 00000000000..7296d6807ec --- /dev/null +++ b/src/components/carousel/nz-carousel-content.directive.ts @@ -0,0 +1,51 @@ +import { + Directive, + HostBinding +} from '@angular/core'; +@Directive({ + selector: '[nz-carousel-content]', +}) +export class NzCarouselContentDirective { + width = 0; + isActive = false; + left = null; + top = null; + fadeMode = false; + + @HostBinding('class.slick-slide') true; + + @HostBinding('class.slick-active') + get setActiveClass() { + return this.isActive === true; + } + + @HostBinding('style.width.px') + get setWidth() { + return this.width; + } + + @HostBinding('style.left.px') + get setLeft() { + return this.left; + } + + @HostBinding('style.top.px') + get setTop() { + return this.top; + } + + @HostBinding('style.position') + get setPosition() { + if (this.fadeMode) { + return 'relative'; + } + } + + @HostBinding('style.opacity') + get setOpacity() { + if (this.fadeMode) { + return this.isActive ? 1 : 0; + } + } + +} diff --git a/src/components/carousel/nz-carousel.component.ts b/src/components/carousel/nz-carousel.component.ts new file mode 100755 index 00000000000..90ddf786d4f --- /dev/null +++ b/src/components/carousel/nz-carousel.component.ts @@ -0,0 +1,118 @@ +import { + Component, + ContentChildren, + ViewChild, + HostBinding, + AfterViewInit, + Renderer2, + OnDestroy, + Input, + ElementRef, + ViewEncapsulation +} from '@angular/core'; +import { NzCarouselContentDirective } from './nz-carousel-content.directive'; + +@Component({ + selector : 'nz-carousel', + encapsulation: ViewEncapsulation.None, + template : ` +
+
+
+ +
+
+
    +
  • + +
  • +
+
`, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) +export class NzCarouselComponent implements AfterViewInit, OnDestroy { + activeIndex = 0; + transform = 'translate3d(0px, 0px, 0px)'; + interval; + @ContentChildren(NzCarouselContentDirective) slideContents; + @ViewChild('slickList') slickList: ElementRef; + @ViewChild('slickTrack') slickTrack: ElementRef; + @Input() nzAutoPlay = false; + @Input() nzDots = true; + @Input() nzEffect = 'scrollx'; + @Input() @HostBinding('class.ant-carousel-vertical') nzVertical = false; + @HostBinding('class.ant-carousel') true; + + constructor(public hostElement: ElementRef, private _renderer: Renderer2) { + } + + setActive(content, i) { + this.clearInterval(); + this.createInterval(); + this.activeIndex = i; + if (this.nzEffect !== 'fade') { + if (!this.nzVertical) { + this.transform = `translate3d(${-this.activeIndex * this.hostElement.nativeElement.offsetWidth}px, 0px, 0px)`; + } else { + this.transform = `translate3d(0px, ${-this.activeIndex * this.hostElement.nativeElement.offsetHeight}px, 0px)`; + } + } + this.slideContents.forEach(slide => { + slide.isActive = false; + }); + content.isActive = true; + } + + ngAfterViewInit() { + setTimeout(_ => { + this.slideContents.first.isActive = true; + this.slideContents.forEach((content, i) => { + content.width = this.hostElement.nativeElement.offsetWidth; + if (this.nzEffect === 'fade') { + content.fadeMode = true; + if (!this.nzVertical) { + content.left = -i * content.width; + } else { + content.top = -i * this.hostElement.nativeElement.offsetHeight; + } + } + }); + if (this.nzAutoPlay) { + this.createInterval(); + } + this._renderer.setStyle(this.slickList.nativeElement, 'height', `${this.hostElement.nativeElement.offsetHeight}px`); + if (this.nzVertical) { + this._renderer.setStyle(this.slickTrack.nativeElement, 'height', `${this.slideContents.length * this.hostElement.nativeElement.offsetHeight}px`); + } else { + this._renderer.setStyle(this.slickTrack.nativeElement, 'width', `${this.slideContents.length * this.hostElement.nativeElement.offsetWidth}px`); + } + }) + + } + + createInterval() { + this.interval = setInterval(_ => { + if (this.activeIndex < this.slideContents.length - 1) { + this.activeIndex++; + } else { + this.activeIndex = 0; + } + this.setActive(this.slideContents.toArray()[ this.activeIndex ], this.activeIndex); + }, 3000); + } + + clearInterval() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + ngOnDestroy() { + this.clearInterval(); + } + +} diff --git a/src/components/carousel/nz-carousel.module.ts b/src/components/carousel/nz-carousel.module.ts new file mode 100644 index 00000000000..e1128b5d721 --- /dev/null +++ b/src/components/carousel/nz-carousel.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { NzCarouselComponent } from './nz-carousel.component'; +import { NzCarouselContentDirective } from './nz-carousel-content.directive'; +import { CommonModule } from '@angular/common'; +@NgModule({ + declarations: [ NzCarouselComponent, NzCarouselContentDirective ], + exports : [ NzCarouselComponent, NzCarouselContentDirective ], + imports : [ CommonModule ] +}) + +export class NzCarouselModule { +} diff --git a/src/components/carousel/style/index.less b/src/components/carousel/style/index.less new file mode 100755 index 00000000000..73ea54c3fa4 --- /dev/null +++ b/src/components/carousel/style/index.less @@ -0,0 +1,206 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; + +.@{ant-prefix}-carousel { + .slick-slider { + position: relative; + display: block; + box-sizing: border-box; + -webkit-touch-callout: none; + -ms-touch-action: pan-y; + touch-action: pan-y; + -webkit-tap-highlight-color: transparent; + } + .slick-list { + position: relative; + overflow: hidden; + display: block; + margin: 0; + padding: 0; + + &:focus { + outline: none; + } + + &.dragging { + cursor: pointer; + } + } + .slick-slider .slick-track, + .slick-slider .slick-list { + transform: translate3d(0, 0, 0); + } + + .slick-track { + position: relative; + left: 0; + top: 0; + display: block; + + &:before, + &:after { + content: ""; + display: table; + } + + &:after { + clear: both; + } + + .slick-loading & { + visibility: hidden; + } + } + .slick-slide { + float: left; + height: 100%; + min-height: 1px; + [dir="rtl"] & { + float: right; + } + img { + display: block; + } + &.slick-loading img { + display: none; + } + + display: none; + + &.dragging img { + pointer-events: none; + } + } + + .slick-initialized .slick-slide { + display: block; + } + + .slick-loading .slick-slide { + visibility: hidden; + } + + .slick-vertical .slick-slide { + display: block; + height: auto; + border: @border-width-base @border-style-base transparent; + } + .slick-arrow.slick-hidden { + display: none; + } + + // Arrows + .slick-prev, + .slick-next { + position: absolute; + display: block; + height: 20px; + width: 20px; + line-height: 0; + font-size: 0; + cursor: pointer; + background: transparent; + color: transparent; + top: 50%; + margin-top: -10px; + padding: 0; + border: 0; + outline: none; + &:hover, + &:focus { + outline: none; + background: transparent; + color: transparent; + &:before { + opacity: 1; + } + } + &.slick-disabled:before { + opacity: 0.25; + } + } + + .slick-prev { + left: -25px; + &:before { + content: "←"; + } + } + + .slick-next { + right: -25px; + &:before { + content: "→"; + } + } + + // Dots + .slick-dots { + position: absolute; + bottom: 12px; + list-style: none; + display: block; + text-align: center; + padding: 0; + width: 100%; + height: @carousel-dot-height; + li { + position: relative; + display: inline-block; + vertical-align: top; + text-align: center; + margin: 0 2px; + padding: 0; + button { + border: 0; + cursor: pointer; + background: #fff; + opacity: 0.3; + display: block; + width: @carousel-dot-width; + height: @carousel-dot-height; + border-radius: 1px; + outline: none; + font-size: 0; + color: transparent; + transition: all .5s; + &:hover, + &:focus { + opacity: 0.75; + } + } + &.slick-active button { + background: #fff; + opacity: 1; + width: @carousel-dot-active-width; + &:hover, + &:focus { + opacity: 1; + } + } + } + } +} + +.@{ant-prefix}-carousel-vertical { + .slick-dots { + width: @carousel-dot-height; + bottom: auto; + right: 12px; + top: 50%; + transform: translateY(-50%); + height: auto; + li { + margin: 0 2px; + vertical-align: baseline; + button { + width: @carousel-dot-height; + height: @carousel-dot-width; + } + &.slick-active button { + width: @carousel-dot-height; + height: @carousel-dot-active-width; + } + } + } +} diff --git a/src/components/carousel/style/patch.less b/src/components/carousel/style/patch.less new file mode 100644 index 00000000000..70a6b9bbd87 --- /dev/null +++ b/src/components/carousel/style/patch.less @@ -0,0 +1,12 @@ +nz-carousel { + display: block; + position: relative; + width: 100%; + height: 100%; + .slick-track { + transition: all 0.5s ease; + .slick-slide{ + transition: opacity 500ms ease; + } + } +} diff --git a/src/components/cascader/nz-cascader.component.ts b/src/components/cascader/nz-cascader.component.ts new file mode 100644 index 00000000000..440cb14fb51 --- /dev/null +++ b/src/components/cascader/nz-cascader.component.ts @@ -0,0 +1,1040 @@ +import { + Component, + OnInit, + OnDestroy, + OnChanges, + AfterViewInit, + SimpleChanges, + ViewEncapsulation, + Input, + ChangeDetectorRef, + ElementRef, + Renderer2, + HostListener, + forwardRef, + HostBinding, + Output, + EventEmitter, + ViewChild, + TemplateRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { + BACKSPACE, + ESCAPE, + LEFT_ARROW, + RIGHT_ARROW, + UP_ARROW, + DOWN_ARROW, + ENTER, +} from '@angular/cdk'; +const ESC = 27; + +import { DropDownAnimation } from '../core/animation/dropdown-animations'; +import { ConnectionPositionPair } from '../core/overlay'; + +function noop(): void { } + +function toArray(value: any): any[] { + let ret = value; + if (value === undefined) { + ret = []; + } else if (!Array.isArray(value)) { + ret = [value]; + } + return ret; +} + +function arrayEquals(array1: any[], array2: any[]): boolean { + if (!array1 || !array2 || array1.length !== array2.length) { + return false; + } + + const len = array1.length; + for (let i = 0; i < len; i++) { + if (array1[i] !== array2[i]) { + return false; + } + } + return true; +} + +const defaultDisplayRender = label => label.join(' / '); + +export type CascaderExpandTrigger = 'click' | 'hover'; +export type CascaderTriggerType = 'click' | 'hover'; + +export interface CascaderOption { + value?: string; + label?: string; + title?: string; + disabled?: boolean; + loading?: boolean; + isLeaf?: boolean; + parent?: CascaderOption; + children?: CascaderOption[]; + [key: string]: any; +} + + +@Component({ + selector : 'nz-cascader', + encapsulation: ViewEncapsulation.None, + animations : [ + DropDownAnimation + ], + template : ` +
+
+ + + + + + + + {{_displayLabel}} + +
+ +
+ +
+
    +
  • + {{_getOptionLabel(option)}} +
  • +
+
+
+ `, + providers : [ + { + provide : NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NzCascaderComponent), + multi : true + } + ], + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) +export class NzCascaderComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit, ControlValueAccessor { + _el: HTMLElement; + _prefixCls = 'ant-cascader'; + _inputPrefixCls = 'ant-input'; + _dropDownPosition = 'bottom'; + _focused = false; + _popupVisible = false; + + _displayLabel: string | TemplateRef; + _displayLabelIsTemplate = false; + _displayLabelContext: any = {}; + + __inputValue = ''; + get _inputValue() { + return this.__inputValue; + } + set _inputValue(inputValue) { + this.__inputValue = inputValue; + if (inputValue.length) { + this._addHostClass(`${this._prefixCls}-picker-with-value`); + } else { + this._removeHostClass(`${this._prefixCls}-picker-with-value`); + } + } + + + // check if change happened + _lastValue: any[]; + // selection will trigger value change + _selectedOptions: CascaderOption[] = []; + // activaction will not triiger value change + _activatedOptions: CascaderOption[] = []; + // all data columns + _nzColumns: CascaderOption[][] = []; + + // 点击Document的事件(一般用于点击后隐藏菜单) + private _clickOutsideHandler: Function; + private _touchOutsideHandler: Function; + private _delayTimer: any; + + // ngModel Access + onChange: any = Function.prototype; + onTouched: any = Function.prototype; + + /** Whether is disabled */ + @Input() nzDisabled = false; + + /** Input size, one of `large` `default` `small` */ + @Input() nzSize: 'large' | 'default' | 'small' = 'default'; + + /** Input placeholder */ + @Input() nzPlaceHolder = 'Please select'; + + /** Whether show input box. Defaults to `true`. */ + @Input() nzShowInput = true; + + /** Whether can search. Defaults to `false`. */ + @Input() nzShowSearch = false; + + /** Whether allow clear. Defaults to `true`. */ + @Input() nzAllowClear = true; + + /** Hover text for the clear icon */ + @Input() nzClearText = 'Clear'; + + /** Whether to show arrow */ + @Input() nzShowArrow = true; + + /** Specify content to show when no result matches. */ + @Input() nzNotFoundContent = 'Not Found'; + + /** Additional className of popup overlay */ + @Input() nzPopupClassName: string; + + /** Additional className of popup overlay column */ + @Input() nzColumnClassName: string; + + /** Options for first column, sub column will be load async */ + @Input() nzOptions: CascaderOption[]; + + /** Whether cache children when they were loaded asych */ + @Input() nzEnableCache = true; + + /** Expand column item when click or hover, one of 'click' 'hover' */ + @Input() nzExpandTrigger: CascaderExpandTrigger = 'click'; + + /** Change value on each selection if set to true */ + @Input() nzChangeOnSelect = false; + + /** Change value on selection only if this function returns `true` */ + @Input() nzChangeOn: (option: CascaderOption, level: number) => boolean; + + /** Delay time to show when mouse enter, when `nzExpandTrigger` is `hover`. */ + @Input() nzMouseEnterDelay = 150; // ms + + /** Delay time to hide when mouse enter, when `nzExpandTrigger` is `hover`. */ + @Input() nzMouseLeaveDelay = 150; // ms + + /** Triggering mode: can be Array<'click'|'hover'> */ + @Input() nzTriggerAction: CascaderTriggerType | CascaderTriggerType[] = ['click']; + + /** Render function of displaying selected options */ + @Input() nzDisplayRender: (label: string[], selectedOptions: CascaderOption[]) => string | TemplateRef; + + /** Property name for getting `value` in the option */ + @Input() nzValueProperty = 'value'; + + /** Property name for getting `label` in the option */ + @Input() nzLabelProperty = 'label'; + + @ViewChild('menu') menu: ElementRef; + + @HostBinding('attr.tabIndex') tabIndex = '0'; + + + /** Event: emit on popup show or hide */ + @Output() nzVisibleChange = new EventEmitter(); + + /** Event: emit on values changed */ + @Output() nzChange = new EventEmitter(); + + /** Event: emit on values and selection changed */ + @Output() nzSelectionChange = new EventEmitter(); + + /** + * Event: emit on option selected, event data:{option: any, index: number} + */ + @Output() nzSelect = new EventEmitter<{ + option: CascaderOption, + index: number + }>(); + + /** + * Event: emit before loading children. event data:{option: any|null, index: number, resolve, reject} + */ + @Output() nzLoad = new EventEmitter<{ + option: CascaderOption, + index: number, + resolve: (children: CascaderOption[]) => void, + reject: () => void + }>(); + + /** Event: emit on the clear button clicked */ + @Output() nzClear = new EventEmitter(); + + + onPositionChange(position) { + const _position = position.connectionPair.originY === 'bottom' ? 'bottom' : 'top'; + if (this._dropDownPosition !== _position) { + this._dropDownPosition = _position; + this._cdr.detectChanges(); + } + } + + nzFocus() { + this._focused = true; + this._addHostClass(`${this._prefixCls}-focused`); + } + + nzBlur() { + this._focused = false; + this._removeHostClass(`${this._prefixCls}-focused`); + } + + get _pickerLabelCls(): any { + return { + [`${this._prefixCls}-picker-label`]: true + }; + } + + get _arrowCls(): any { + return { + [`${this._prefixCls}-picker-arrow`] : true, + [`${this._prefixCls}-picker-arrow-expand`]: this._popupVisible + }; + } + + get _clearCls(): any { + return { + [`${this._prefixCls}-picker-clear`]: true + }; + } + + get _inputCls(): any { + return { + [`${this._prefixCls}-input`] : 1, + [`${this._inputPrefixCls}-disabled`]: this.nzDisabled, + [`${this._inputPrefixCls}-lg`] : this.nzSize === 'large', + [`${this._inputPrefixCls}-sm`] : this.nzSize === 'small', + }; + } + + get _menuCls(): any { + return { + [`${this._prefixCls}-menus`] : true, + [`${this._prefixCls}-menus-hidden`]: !this._popupVisible, + [`${this.nzPopupClassName}`] : this.nzPopupClassName + }; + } + + /** 获取菜单中列的样式 */ + get _columnCls(): any { + return { + [`${this._prefixCls}-menu`] : true, + [`${this.nzColumnClassName}`]: this.nzColumnClassName + }; + } + + /** 获取列中Option的样式 */ + _getOptionCls(option: CascaderOption, index: number): any { + return { + [`${this._prefixCls}-menu-item`] : true, + [`${this._prefixCls}-menu-item-expand`] : !option.isLeaf, + [`${this._prefixCls}-menu-item-active`] : this._isActiveOption(option, index), + [`${this._prefixCls}-menu-item-disabled`]: option.disabled, + [`${this._prefixCls}-menu-item-loading`] : option.loading + }; + } + + + + _getLabel() { + return this._displayLabelIsTemplate ? '' : this._displayLabel; + } + + /** prevent input change event */ + _handlerInputChange(event: Event): void { + event.stopPropagation(); + } + + /** input blur */ + _handleInputBlur(event: Event): void { + if (!this.nzShowSearch) { + return; + } + if (this._popupVisible) { + this.nzFocus(); + } else { + this.nzBlur(); + } + } + + /** input focus */ + _handleInputFocus(event: Event): void { + if (!this.nzShowSearch) { + return; + } + this.nzFocus(); + } + + /** input key down */ + _handleInputKeyDown(event: KeyboardEvent): void { + + } + + _setInputValue(inputValue: any, fireSearch = true): void { + if (inputValue !== this._inputValue) { + this._inputValue = inputValue; + } + } + + + _hasInput(): boolean { + return this._inputValue.length > 0; + } + + _hasSelection(): boolean { + return this._selectedOptions.length > 0; + } + + /** Whether the clear button is visible */ + get _showClearIcon(): boolean { + const isSelected = this._hasSelection(); + const isHasInput = this._hasInput(); + return this.nzAllowClear && !this.nzDisabled && (isSelected || isHasInput); + } + + /** clear the input box and selected options */ + _clearSelection(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + this._displayLabel = ''; + this._displayLabelIsTemplate = false; + this._selectedOptions = []; + this._activatedOptions = []; + this._setInputValue('', false); + this.setPopupVisible(false); + + // trigger change event + this._onValueChange(); + } + + get _displayRender(): (label: string[], selectedOptions: CascaderOption[]) => string | TemplateRef { + return this.nzDisplayRender || defaultDisplayRender; + } + + _buildDisplayLabel(): void { + const selectedOptions = this._selectedOptions; + const labels = selectedOptions.map(o => o[this.nzLabelProperty || 'label']); + // 设置当前控件的显示值 + this._displayLabel = this._displayRender.call(this, labels, selectedOptions); + this._displayLabelIsTemplate = !(typeof this._displayLabel === 'string'); + this._displayLabelContext = {labels, selectedOptions}; + } + + /** 由用户来定义点击后是否变更 */ + _isChangeOn(option: CascaderOption, index: number): boolean { + if (typeof this.nzChangeOn === 'function') { + return this.nzChangeOn(option, index) === true; + } + return false; + } + + + @HostListener('keydown', ['$event']) + onKeyDown(event: KeyboardEvent): void { + const keyCode = event.keyCode; + if (keyCode !== DOWN_ARROW && + keyCode !== UP_ARROW && + keyCode !== LEFT_ARROW && + keyCode !== RIGHT_ARROW && + keyCode !== ENTER && + keyCode !== BACKSPACE && + keyCode !== ESC) { + return; + } + + // Press any keys above to reopen menu + if (!this._isPopupVisible() && + keyCode !== BACKSPACE && + keyCode !== ESC) { + this.setPopupVisible(true); + return; + } + // Press ESC to close menu + if (keyCode === ESC) { + this.setPopupVisible(false); + return; + } + + if (this._isPopupVisible()) { + event.preventDefault(); + if (keyCode === DOWN_ARROW) { + this._moveDown(); + } + if (keyCode === UP_ARROW) { + this._moveUp(); + } + if (keyCode === LEFT_ARROW) { + this._moveLeft(); + } + if (keyCode === RIGHT_ARROW) { + this._moveRight(); + } + } + } + + + @HostListener('click', ['$event']) + _onTriggerClick(event: MouseEvent): void { + if (this.nzDisabled) { + return; + } + this.onTouched(); // set your control to 'touched' + + if (this._isClickTiggerAction()) { + this._delaySetPopupVisible(!this._popupVisible, 100); + } + } + + @HostListener('mouseenter', ['$event']) + _onTriggerMouseEnter(event: MouseEvent): void { + if (this.nzDisabled) { + return; + } + if (this._isPointerTiggerAction()) { + this._delaySetPopupVisible(true, this.nzMouseEnterDelay); + } + } + + @HostListener('mouseleave', ['$event']) + _onTriggerMouseLeave(event: MouseEvent): void { + if (this.nzDisabled) { + return; + } + if (!this._isPopupVisible()) { + return; + } + if (this._isPointerTiggerAction()) { + const currEl = this._el; + const popupEl = this.menu && this.menu.nativeElement as HTMLElement; + if (currEl.contains(event.target as Node) || + (popupEl && popupEl.contains(event.target as Node))) { + return; // 还在菜单内部 + } + this._delaySetPopupVisible(false, this.nzMouseLeaveDelay); + } + } + + _isClickTiggerAction() { + if (typeof this.nzTriggerAction === 'string') { + return this.nzTriggerAction === 'click'; + } + return this.nzTriggerAction.indexOf('click') !== -1; + } + + _isPointerTiggerAction() { + if (typeof this.nzTriggerAction === 'string') { + return this.nzTriggerAction === 'hover'; + } + return this.nzTriggerAction.indexOf('hover') !== -1; + } + + _closeMenu(): void { + this._clearDelayTimer(); + this.setPopupVisible(false); + } + + /** + * 显示或者隐藏菜单 + * + * @param visible true-显示,false-隐藏 + * @param delay 延迟时间 + */ + _delaySetPopupVisible(visible: boolean, delay: number): void { + this._clearDelayTimer(); + if (delay) { + this._delayTimer = setTimeout(() => { + this.setPopupVisible(visible); + this._clearDelayTimer(); + }, delay); + } else { + this.setPopupVisible(visible); + } + } + + _isPopupVisible(): boolean { + return this._popupVisible; + } + + setPopupVisible(popupVisible: boolean): void { + if (this.nzDisabled) { + return; + } + + if (this._popupVisible !== popupVisible) { + this._popupVisible = popupVisible; + + // We must listen to `mousedown` or `touchstart`, edge case: + // https://github.com/ant-design/ant-design/issues/5804 + // https://github.com/react-component/calendar/issues/250 + // https://github.com/react-component/trigger/issues/50 + if (popupVisible) { + if (!this._clickOutsideHandler) { + this._clickOutsideHandler = this._render.listen('document', 'mousedown', this._onDocumentClick.bind(this)); + } + // always hide on mobile + if (!this._touchOutsideHandler) { + this._touchOutsideHandler = this._render.listen('document', 'touchstart', this._onDocumentClick.bind(this)); + } + } + if (!popupVisible) { + this._clearOutsideHandler(); + } + + if (popupVisible) { + this._beforeVisible(); + } + this.nzVisibleChange.emit(popupVisible); + } + } + + /** load init data if necessary */ + _beforeVisible(): void { + if (!this._nzColumns.length) { + new Promise((resolve, reject) => { + this.nzLoad.emit({ + option: null, + index: -1, + resolve, + reject + }); + }).then((children: CascaderOption[]) => { + this._setColumnData(children, 0); + }, (reason: any) => { + // should not be here + }); + } + } + + _onDocumentClick(event: MouseEvent): void { + const target = event.target as Node; + const popupEl = this.menu && this.menu.nativeElement as HTMLElement; + if (!this._el.contains(target) && !popupEl.contains(target)) { + this.setPopupVisible(false); + } + } + + _clearOutsideHandler(): void { + if (this._clickOutsideHandler) { + this._clickOutsideHandler(); // Removes "listen" listener + this._clickOutsideHandler = null; + } + + if (this._touchOutsideHandler) { + this._touchOutsideHandler(); // Removes "listen" listener + this._touchOutsideHandler = null; + } + } + + _clearDelayTimer(): void { + if (this._delayTimer) { + clearTimeout(this._delayTimer); + this._delayTimer = null; + } + } + + /** 获取Option的值,例如,可以指定labelProperty="name"来取Name */ + _getOptionLabel(option: CascaderOption): any { + return option[this.nzLabelProperty || 'label']; + } + + /** 获取Option的值,例如,可以指定valueProperty="id"来取ID */ + _getOptionValue(option: CascaderOption): any { + return option[this.nzValueProperty || 'value']; + } + + _isActiveOption(option: CascaderOption, index: number): boolean { + const activeOpt = this._activatedOptions[index]; + if (activeOpt === option) { + return true; + } + if (activeOpt && this._getOptionValue(activeOpt) === this._getOptionValue(option)) { + return true; + } + return false; + } + + /** + * 设置某列的激活的菜单选项 + * + * @param option 菜单选项 + * @param index 选项所在的列组的索引 + */ + _setActiveOption(option: CascaderOption, index: number): void { + if (!option || option.disabled) { + return; + } + + this._activatedOptions[index] = option; + + // 当直接选择最后一级时,前面的选项要补全。例如,选择“城市”,则自动补全“国家”、“省份” + for (let i = index - 1; i >= 0; i--) { + if (!this._activatedOptions[i]) { + this._activatedOptions[i] = this._activatedOptions[i + 1].parent; + } + } + // 截断多余的选项,如选择“省份”,则只会有“国家”、“省份”,去掉“城市”、“区县” + if (index < this._activatedOptions.length - 1) { + this._activatedOptions = this._activatedOptions.slice(0, index + 1); + } + + // trigger select event, and display label + this._onSelectOption(option, index); + } + + _onSelectOption(option: CascaderOption, index: number): void { + // trigger `nzSelect` event + this.nzSelect.emit({ + option: option, + index: index + }); + + // load children directly + if (option.children && option.children.length) { + option.isLeaf = false; + option.children.forEach(child => child.parent = option); + this._setColumnData(option.children, index + 1); + } else if (!option.isLeaf) { + // load children async + new Promise((resolve, reject) => { + this.nzLoad.emit({ + option: option, + index: index, + resolve, + reject + }); + }).then((children: CascaderOption[]) => { + children.forEach(child => child.parent = option); + this._setColumnData(children, index + 1); + if (this.nzEnableCache) { + option.children = children; // next time we load children directly + } + }, (reason: any) => { + option.isLeaf = true; + }); + } + + // 生成显示 + if (option.isLeaf || this.nzChangeOnSelect || this._isChangeOn(option, index)) { + this._selectedOptions = this._activatedOptions; + // 设置当前控件的显示值 + this._buildDisplayLabel(); + // 触发变更事件 + this._onValueChange(); + } + + // close menu if click on leaf + if (option.isLeaf) { + this._delaySetPopupVisible(false, this.nzMouseLeaveDelay); + } + } + + _setColumnData(options: CascaderOption[], index: number): void { + if (!arrayEquals(this._nzColumns[index], options)) { + this._nzColumns[index] = options; + if (index < this._nzColumns.length - 1) { + this._nzColumns = this._nzColumns.slice(0, index + 1); + } + } + } + + + /** + * 鼠标点击选项 + * + * @param option 菜单选项 + * @param index 选项所在的列组的索引 + * @param event 鼠标事件 + */ + _onOptionClick(option: CascaderOption, index: number, event: Event): void { + event.preventDefault(); + + // Keep focused state for keyboard support + this._el.focus(); + + if (option && option.disabled) { + return; + } + this._setActiveOption(option, index); + } + + /** + * press `up` or `down` arrow to select the sibling option. + */ + _moveUpOrDown(isUp: boolean): void { + const columnIndex = Math.max(this._activatedOptions.length - 1, 0); + // 该组中已经被激活的选项 + const activeOption = this._activatedOptions[columnIndex]; + // 该组所有的选项,用于遍历获取下一个被激活的选项 + const options = this._nzColumns[columnIndex]; + if (!options || !options.length) { + return; + } + + const length = options.length; + let nextOptIndex = -1; + if (!activeOption) { // 该列还没有选中的选项 + nextOptIndex = isUp ? length : -1; + } else { + nextOptIndex = options.indexOf(activeOption); + } + + while (true) { + nextOptIndex = isUp ? nextOptIndex - 1 : nextOptIndex + 1; + if (nextOptIndex < 0 || nextOptIndex >= length) { + break; + } + const nextOption = options[nextOptIndex]; + if (!nextOption || nextOption.disabled) { + continue; + } + this._setActiveOption(nextOption, columnIndex); + break; + } + } + + _moveUp(): void { + this._moveUpOrDown(true); + } + _moveDown(): void { + this._moveUpOrDown(false); + } + + /** + * press `left` arrow to remove the last selected option. + * If there is no option selected, emit `nzClear` event. + */ + _moveLeft(): void { + const options = this._selectedOptions; + if (options.length) { + options.pop(); // Remove the last one + const len = options.length; + if (len) { + this._setActiveOption(options[len - 1], len - 1); + } else { + this.nzClear.emit(); + } + } + } + + /** + * press `right` arrow to select the next column option. + */ + _moveRight(): void { + const columns = this._nzColumns; + const length = this._selectedOptions.length; + if (length === 0) { + return; + } + + const nextColIndex = length; + const options = columns.length > nextColIndex ? columns[nextColIndex] : null; + if (options) { // 存在`下级选项` + const len = options.length; + for (let i = 0; i < len; i++) { + const activeOpt = options[i]; + if (activeOpt && !activeOpt.disabled) { + this._setActiveOption(activeOpt, nextColIndex); + return; + } + } + } + } + + + + /** + * 鼠标划入选项 + * + * @param option 菜单选项 + * @param index 选项所在的列组的索引 + * @param event 鼠标事件 + */ + _onOptionMouseEnter(option: CascaderOption, index: number, event: Event): void { + event.preventDefault(); + if (this.nzExpandTrigger === 'hover' && !option.isLeaf) { + this._delaySelect(option, index, true); + } + } + + /** + * 鼠标划出选项 + * + * @param option 菜单选项 + * @param index 选项所在的列组的索引 + * @param event 鼠标事件 + */ + _onOptionMouseLeave(option: CascaderOption, index: number, event: Event): void { + event.preventDefault(); + if (this.nzExpandTrigger === 'hover' && !option.isLeaf) { + this._delaySelect(option, index, false); + } + } + + _delaySelect(option: CascaderOption, index: number, doSelect: boolean) { + if (this._delayTimer) { + clearTimeout(this._delayTimer); + this._delayTimer = null; + } + if (doSelect) { + this._delayTimer = setTimeout(() => { + this._setActiveOption(option, index); + this._delayTimer = null; + }, 150); + } + } + + + + _getSubmitValue(): any[] { + const values: any[] = []; + this._selectedOptions.forEach(option => { + values.push(this._getOptionValue(option)); + }); + return values; + } + + _onValueChange(): void { + const value = this._getSubmitValue(); + if (!arrayEquals(this._lastValue, value)) { + this._lastValue = value; + this.onChange(value); // Angular need this + if (value.length === 0) { + this.nzClear.emit(); // first trigger `clear` and then `change` + } + this.nzSelectionChange.emit(this._selectedOptions); + this.nzChange.emit(value); + } + } + + + + + constructor( + private _elementRef: ElementRef, + private _render: Renderer2, + private _cdr: ChangeDetectorRef) { + this._el = this._elementRef.nativeElement; + + } + + _addHostClass(classname: string): void { + this._render.addClass(this._el, classname); + } + + _removeHostClass(classname: string): void { + this._render.removeClass(this._el, classname); + } + + /** + * Write a new value to the element. + * + * @Override (From ControlValueAccessor interface) + */ + writeValue(value: any): void { + if (value == null) { + return; + } + + const array: any[] = []; + toArray(value).forEach((v: any, index: number) => { + if (typeof v !== 'object') { + const obj = {}; + obj[this.nzValueProperty] = v; + obj[this.nzLabelProperty] = v; + array[index] = obj; + } else { + array[index] = v; + } + }); + this._activatedOptions = array; + this._selectedOptions = array; + this._buildDisplayLabel(); + } + + registerOnChange(fn: (_: any) => {}): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => {}): void { + this.onTouched = fn; + } + + ngOnInit() { + // 设置第一列 + if (this.nzOptions && this.nzOptions.length) { + this._nzColumns.push(this.nzOptions); + } + } + + ngOnDestroy(): void { + if (this._delayTimer) { + clearTimeout(this._delayTimer); + this._delayTimer = null; + } + } + + ngOnChanges(changes: SimpleChanges): void { + const nzDisabled = changes['nzDisabled']; + if (nzDisabled) { + if (nzDisabled.currentValue) { + this._addHostClass(`${this._prefixCls}-picker-disabled`); + } else { + this._removeHostClass(`${this._prefixCls}-picker-disabled`); + } + } + } + + ngAfterViewInit(): void { + this._addHostClass(this._prefixCls); + this._addHostClass(`${this._prefixCls}-picker`); + } +} diff --git a/src/components/cascader/nz-cascader.module.ts b/src/components/cascader/nz-cascader.module.ts new file mode 100644 index 00000000000..36d7b364512 --- /dev/null +++ b/src/components/cascader/nz-cascader.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { NzCascaderComponent } from './nz-cascader.component'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { OverlayModule } from '../core/overlay'; +import { NzInputModule } from '../input/nz-input.module'; + +@NgModule({ + imports : [ CommonModule, FormsModule, OverlayModule, NzInputModule ], + declarations: [ + NzCascaderComponent + ], + exports : [ + NzCascaderComponent + ] +}) + +export class NzCascaderModule { +} diff --git a/src/components/cascader/style/index.less b/src/components/cascader/style/index.less new file mode 100755 index 00000000000..339a6c424f9 --- /dev/null +++ b/src/components/cascader/style/index.less @@ -0,0 +1,199 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; +@import "../../input/style/mixin"; + +@cascader-prefix-cls: ~"@{ant-prefix}-cascader"; + +.@{cascader-prefix-cls} { + font-size: @font-size-base; + + &-input.@{ant-prefix}-input { + // Add important to fix https://github.com/ant-design/ant-design/issues/5078 + // because input.less will compile after cascader.less + background-color: transparent !important; + cursor: pointer; + width: 100%; + display: block; + } + + &-picker { + position: relative; + display: inline-block; + cursor: pointer; + font-size: @font-size-base; + background-color: @component-background; + border-radius: @border-radius-base; + outline: 0; + + &-with-value &-label { + color: transparent; + } + + &-disabled { + cursor: not-allowed; + background: @input-disabled-bg; + color: @disabled-color; + .@{cascader-prefix-cls}-input { + cursor: not-allowed; + } + } + + &:focus .@{cascader-prefix-cls}-input { + .active; + } + + &-label { + position: absolute; + left: 0; + height: 20px; + line-height: 20px; + top: 50%; + margin-top: -10px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 100%; + padding: 0 12px 0 8px; + } + + &-clear { + opacity: 0; + position: absolute; + right: 8px; + z-index: 2; + background: @component-background; + top: 50%; + font-size: @font-size-base; + color: @disabled-color; + width: 12px; + height: 12px; + margin-top: -6px; + line-height: 12px; + cursor: pointer; + transition: color 0.3s ease, opacity 0.15s ease; + &:hover { + color: @text-color-secondary; + } + } + + &:hover &-clear { + opacity: 1; + } + + // arrow + &-arrow { + position: absolute; + z-index: 1; + top: 50%; + right: 8px; + width: 12px; + height: 12px; + margin-top: -6px; + line-height: 12px; + color: @text-color-secondary; + .iconfont-size-under-12px(9px); + &:before { + transition: transform 0.2s ease; + } + &&-expand { + .ie-rotate(2); + &:before { + transform: rotate(180deg); + } + } + } + } + &-menus { + font-size: @font-size-base; + background: @component-background; + position: absolute; + z-index: @zindex-dropdown; + border-radius: @border-radius-base; + box-shadow: @box-shadow-base; + white-space: nowrap; + &-empty, + &-hidden { + display: none; + } + &.slide-up-enter.slide-up-enter-active&-placement-bottomLeft, + &.slide-up-appear.slide-up-appear-active&-placement-bottomLeft { + animation-name: antSlideUpIn; + } + + &.slide-up-enter.slide-up-enter-active&-placement-topLeft, + &.slide-up-appear.slide-up-appear-active&-placement-topLeft { + animation-name: antSlideDownIn; + } + + &.slide-up-leave.slide-up-leave-active&-placement-bottomLeft { + animation-name: antSlideUpOut; + } + + &.slide-up-leave.slide-up-leave-active&-placement-topLeft { + animation-name: antSlideDownOut; + } + } + &-menu { + display: inline-block; + vertical-align: top; + min-width: 111px; + height: 180px; + list-style: none; + margin: 0; + padding: 0; + border-right: @border-width-base @border-style-base @border-color-split; + overflow: auto; + &:first-child { + border-radius: @border-radius-base 0 0 @border-radius-base; + } + &:last-child { + border-right-color: transparent; + margin-right: -1px; + border-radius: 0 @border-radius-base @border-radius-base 0; + } + &:only-child { + border-radius: @border-radius-base; + } + } + &-menu-item { + padding: 7px 8px; + cursor: pointer; + white-space: nowrap; + transition: all 0.3s; + &:hover { + background: @item-hover-bg; + } + &-disabled { + cursor: not-allowed; + color: @disabled-color; + &:hover { + background: transparent; + } + } + &-active:not(&-disabled) { + &, + &:hover { + background: @background-color-base; + font-weight: bold; + } + } + &-expand { + position: relative; + &:after { + .iconfont-font("\e61f"); + .iconfont-size-under-12px(8px); + color: @text-color-secondary; + position: absolute; + right: 8px; + } + } + &-loading:after { + .iconfont-font("\e64d"); + animation: loadingCircle 1s infinite linear; + } + + & &-keyword { + color: @highlight-color; + } + } +} diff --git a/src/components/cascader/style/patch.less b/src/components/cascader/style/patch.less new file mode 100644 index 00000000000..08e86278b06 --- /dev/null +++ b/src/components/cascader/style/patch.less @@ -0,0 +1,15 @@ +@import "../../style/themes/default"; + +.@{ant-prefix}-cascader { + display: block; +} + +// +.@{ant-prefix}-cascader-menus { + top: 100%; + left: 0; + position: relative; + width: 100%; + margin-top: 4px; + margin-bottom: 4px; +} diff --git a/src/components/checkbox/nz-checkbox-group.component.ts b/src/components/checkbox/nz-checkbox-group.component.ts new file mode 100644 index 00000000000..292dba8e60f --- /dev/null +++ b/src/components/checkbox/nz-checkbox-group.component.ts @@ -0,0 +1,73 @@ +import { + Component, + OnInit, + ViewEncapsulation, + Input, + ElementRef, + AfterContentInit, + Renderer, + forwardRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector : 'nz-checkbox-group', + encapsulation: ViewEncapsulation.None, + template : ` + `, + providers : [ + { + provide : NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NzCheckboxGroupComponent), + multi : true + } + ], + styleUrls : [ + './style/index.less' + ] +}) +export class NzCheckboxGroupComponent implements OnInit, AfterContentInit, ControlValueAccessor { + _el: HTMLElement; + _options: Array; + _prefixCls = 'ant-checkbox-group'; + // ngModel Access + onChange: any = Function.prototype; + onTouched: any = Function.prototype; + @Input() nzDisabled = false; + @Input() nzType: string; + + _optionChange() { + this.onChange(this._options); + } + + constructor(private _elementRef: ElementRef, private _render: Renderer) { + this._el = this._elementRef.nativeElement; + this._render.setElementClass(this._el, `${this._prefixCls}`, true); + } + + ngAfterContentInit() { + } + + writeValue(value: any): void { + this._options = value; + } + + registerOnChange(fn: (_: any) => {}): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => {}): void { + this.onTouched = fn; + } + + ngOnInit() { + } +} diff --git a/src/components/checkbox/nz-checkbox.component.ts b/src/components/checkbox/nz-checkbox.component.ts new file mode 100644 index 00000000000..cba8a547d3b --- /dev/null +++ b/src/components/checkbox/nz-checkbox.component.ts @@ -0,0 +1,114 @@ +import { + Component, + OnInit, + ViewEncapsulation, + Input, + ElementRef, + Renderer2, + HostListener, + forwardRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector : '[nz-checkbox]', + encapsulation: ViewEncapsulation.None, + template : ` + + + + + + `, + providers : [ + { + provide : NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NzCheckboxComponent), + multi : true + } + ], + styleUrls : [ + './style/index.less' + ] +}) +export class NzCheckboxComponent implements OnInit, ControlValueAccessor { + _el: HTMLElement; + _prefixCls = 'ant-checkbox'; + _innerPrefixCls = `${this._prefixCls}-inner`; + _inputPrefixCls = `${this._prefixCls}-input`; + _checked = false; + _focused = false; + // ngModel Access + onChange: any = Function.prototype; + onTouched: any = Function.prototype; + @Input() nzDisabled = false; + @Input() nzIndeterminate = false; + + @Input() + get nzChecked(): boolean { + return this._checked; + }; + + set nzChecked(value: boolean) { + this.updateValue(value); + } + + + @HostListener('click', [ '$event' ]) + onClick(e) { + e.preventDefault(); + if (!this.nzDisabled) { + this.updateValue(!this._checked); + } + } + + updateValue(value) { + if (value === this._checked) { + return; + } + this.onChange(value); + this._checked = value; + } + + nzFocus() { + this._focused = true; + } + + nzBlur() { + this._focused = false; + } + + get _classMap() { + return { + [this._prefixCls] : true, + [`${this._prefixCls}-checked`] : this._checked && (!this.nzIndeterminate), + [`${this._prefixCls}-focused`] : this._focused, + [`${this._prefixCls}-disabled`] : this.nzDisabled, + [`${this._prefixCls}-indeterminate`]: this.nzIndeterminate, + } + } + + constructor(private _elementRef: ElementRef, private _render: Renderer2) { + this._el = this._elementRef.nativeElement; + } + + writeValue(value: any): void { + this._checked = value; + } + + registerOnChange(fn: (_: any) => {}): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => {}): void { + this.onTouched = fn; + } + + ngOnInit() { + this._render.setAttribute(this._el, 'class', `${this._prefixCls}-wrapper`); + } +} diff --git a/src/components/checkbox/nz-checkbox.module.ts b/src/components/checkbox/nz-checkbox.module.ts new file mode 100644 index 00000000000..eaafc1cd001 --- /dev/null +++ b/src/components/checkbox/nz-checkbox.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { NzCheckboxComponent } from './nz-checkbox.component'; +import { NzCheckboxGroupComponent } from './nz-checkbox-group.component'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@NgModule({ + imports : [ CommonModule, FormsModule ], + declarations: [ + NzCheckboxComponent, + NzCheckboxGroupComponent + ], + exports : [ + NzCheckboxComponent, + NzCheckboxGroupComponent + ] +}) + +export class NzCheckboxModule { +} diff --git a/src/components/checkbox/style/index.less b/src/components/checkbox/style/index.less new file mode 100755 index 00000000000..fa88d365aad --- /dev/null +++ b/src/components/checkbox/style/index.less @@ -0,0 +1,4 @@ +@import "../../style/themes/default"; +@import "./mixin"; + +.antCheckboxFn(); diff --git a/src/components/checkbox/style/mixin.less b/src/components/checkbox/style/mixin.less new file mode 100755 index 00000000000..b8759b4dd9d --- /dev/null +++ b/src/components/checkbox/style/mixin.less @@ -0,0 +1,199 @@ +@import "../../style/mixins/index"; + +.antCheckboxFn(@checkbox-prefix-cls: ~"@{ant-prefix}-checkbox") { + @checkbox-inner-prefix-cls: ~"@{checkbox-prefix-cls}-inner"; + // 一般状态 + .@{checkbox-prefix-cls} { + white-space: nowrap; + cursor: pointer; + outline: none; + display: inline-block; + line-height: 1; + position: relative; + vertical-align: text-bottom; + + .@{checkbox-prefix-cls}-wrapper:hover &-inner, + &:hover &-inner, + &-input:focus + &-inner { + border-color: @primary-color; + } + + &-checked:after { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: @border-radius-sm; + border: 1px solid @primary-color; + content: ''; + animation: antCheckboxEffect 0.36s ease-in-out; + animation-fill-mode: both; + visibility: hidden; + } + + &:hover:after, + .@{checkbox-prefix-cls}-wrapper:hover &:after { + visibility: visible; + } + + &-inner { + position: relative; + top: 0; + left: 0; + display: block; + width: 14px; + height: 14px; + border: @border-width-base @border-style-base @border-color-base; + border-radius: @border-radius-sm; + background-color: #fff; + transition: all .3s; + + &:after { + transform: rotate(45deg) scale(0); + position: absolute; + left: 4px; + top: 1px; + display: table; + width: 5px; + height: 8px; + border: 2px solid #fff; + border-top: 0; + border-left: 0; + content: ' '; + transition: all .1s @ease-in-back; + } + } + + &-input { + position: absolute; + left: 0; + z-index: 1; + cursor: pointer; + .opacity(0); + top: 0; + bottom: 0; + right: 0; + width: 100%; + height: 100%; + } + } + + // 半选状态 + .@{checkbox-prefix-cls}-indeterminate .@{checkbox-inner-prefix-cls}:after { + content: ' '; + transform: scale(1); + position: absolute; + left: 2px; + top: 5px; + width: 8px; + height: 1px; + } + + .@{checkbox-prefix-cls}-indeterminate.@{checkbox-prefix-cls}-disabled .@{checkbox-inner-prefix-cls}:after { + border-color: @disabled-color; + } + + // 选中状态 + .@{checkbox-prefix-cls}-checked .@{checkbox-inner-prefix-cls}:after { + transform: rotate(45deg) scale(1); + position: absolute; + left: 4px; + top: 1px; + display: table; + width: 5px; + height: 8px; + border: 2px solid #fff; + border-top: 0; + border-left: 0; + content: ' '; + transition: all .2s @ease-out-back .1s; + } + + .@{checkbox-prefix-cls}-checked, + .@{checkbox-prefix-cls}-indeterminate { + .@{checkbox-inner-prefix-cls} { + background-color: @primary-color; + border-color: @primary-color; + } + } + + .@{checkbox-prefix-cls}-disabled { + cursor: not-allowed; + + &.@{checkbox-prefix-cls}-checked { + .@{checkbox-inner-prefix-cls}:after { + animation-name: none; + border-color: @disabled-color; + } + } + + .@{checkbox-prefix-cls}-input { + cursor: not-allowed; + } + + .@{checkbox-inner-prefix-cls} { + border-color: @border-color-base !important; + background-color: @input-disabled-bg; + &:after { + animation-name: none; + border-color: @input-disabled-bg; + } + } + + & + span { + color: @disabled-color; + cursor: not-allowed; + } + } + + .@{checkbox-prefix-cls}-wrapper { + cursor: pointer; + font-size: @font-size-base; + display: inline-block; + &:not(:last-child) { + margin-right: 8px; + } + } + + .@{checkbox-prefix-cls}-wrapper + span, + .@{checkbox-prefix-cls} + span { + padding-left: 8px; + padding-right: 8px; + } + + .@{checkbox-prefix-cls}-group { + font-size: @font-size-base; + &-item { + display: inline-block; + } + } + + @ie8: \0screen; + + // IE8 hack for https://github.com/ant-design/ant-design/issues/2148 + @media @ie8 { + .@{checkbox-prefix-cls}-checked .@{checkbox-prefix-cls}-inner:before, + .@{checkbox-prefix-cls}-checked .@{checkbox-prefix-cls}-inner:after { + .iconfont-font("\e632"); + font-weight: bold; + font-size: 8px; + border: 0; + color: #fff; + left: 2px; + top: 3px; + position: absolute; + } + } +} + +@keyframes antCheckboxEffect { + 0% { + transform: scale(1); + opacity: 0.5; + } + 100% { + transform: scale(1.6); + opacity: 0; + } +} diff --git a/src/components/collapse/nz-collapse.component.spec.ts b/src/components/collapse/nz-collapse.component.spec.ts new file mode 100644 index 00000000000..020a3e392e7 --- /dev/null +++ b/src/components/collapse/nz-collapse.component.spec.ts @@ -0,0 +1,32 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; + +import {NzCollapsesetComponent} from './nz-collapseset.component'; +import {NzCollapseComponent} from './nz-collapse.component'; + +describe('My First Test', () => { + it('should get "Hello Taobao"', () => { + }); +}); + +describe('NzCollapsesetComponent', () => { + let component: NzCollapsesetComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [NzCollapseComponent, NzCollapsesetComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NzCollapsesetComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('测试input - accordion : string', () => { + component.nzAccordion = true; + fixture.detectChanges(); + // expect(a).toEqual(b); + }); +}); diff --git a/src/components/collapse/nz-collapse.component.ts b/src/components/collapse/nz-collapse.component.ts new file mode 100644 index 00000000000..3a26c1715a0 --- /dev/null +++ b/src/components/collapse/nz-collapse.component.ts @@ -0,0 +1,80 @@ +import { + Component, + Input, + ElementRef, + Host, HostBinding +} from '@angular/core'; +import { + trigger, + state, + style, + animate, + transition +} from '@angular/animations'; +import { NzCollapsesetComponent } from './nz-collapseset.component' + +@Component({ + selector : 'nz-collapse', + template : ` + +
+
+ +
+
+ `, + animations : [ + trigger('collapseState', [ + state('inactive', style({ + opacity: '0', + height : 0 + })), + state('active', style({ + opacity: '1', + height : '*' + })), + transition('inactive => active', animate('150ms ease-in')), + transition('active => inactive', animate('150ms ease-out')) + ]) + ] +}) + +export class NzCollapseComponent { + _el; + _active: boolean; + + @HostBinding('class.ant-collapse-item') true; + + @Input() nzTitle: string; + @Input() + @HostBinding('class.ant-collapse-item-disabled') + nzDisabled = false; + + @Input() + get nzActive(): boolean { + return this._active; + } + + set nzActive(active: boolean) { + if (this._active === active) { + return; + } + if (!this.nzDisabled) { + this._active = active; + } + } + + clickHeader($event) { + this.nzActive = !this.nzActive; + /** trigger host collapseSet click event */ + this._collapseSet.nzClick(this); + } + + constructor(@Host() private _collapseSet: NzCollapsesetComponent, private _elementRef: ElementRef) { + this._el = this._elementRef.nativeElement; + this._collapseSet.addTab(this); + } +} diff --git a/src/components/collapse/nz-collapse.module.ts b/src/components/collapse/nz-collapse.module.ts new file mode 100644 index 00000000000..9cd5202102d --- /dev/null +++ b/src/components/collapse/nz-collapse.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NzCollapseComponent } from './nz-collapse.component'; +import { NzCollapsesetComponent } from './nz-collapseset.component'; + +export const NZ_COLLAPSE_DIRECTIVES: Array = [ NzCollapsesetComponent, NzCollapseComponent ]; + +@NgModule({ + declarations: NZ_COLLAPSE_DIRECTIVES, + exports : NZ_COLLAPSE_DIRECTIVES, + imports : [ CommonModule ] +}) + +export class NzCollapseModule { +} diff --git a/src/components/collapse/nz-collapseset.component.ts b/src/components/collapse/nz-collapseset.component.ts new file mode 100644 index 00000000000..3c89b3984c1 --- /dev/null +++ b/src/components/collapse/nz-collapseset.component.ts @@ -0,0 +1,51 @@ +import { + Component, + ViewEncapsulation, + Input +} from '@angular/core'; +import { NzCollapseComponent } from './nz-collapse.component'; + +@Component({ + selector : 'nz-collapseset', + encapsulation: ViewEncapsulation.None, + template : ` +
+ +
+ `, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) + + +export class NzCollapsesetComponent { + /** + * all child collapse + * @type {Array} + */ + panels: Array = []; + + @Input() nzAccordion = false; + + @Input() nzBordered = true; + + nzClick(collapse) { + if (this.nzAccordion) { + this.panels.map((item, index) => { + const curIndex = this.panels.indexOf(collapse); + if (index !== curIndex) { + item.nzActive = false; + } + }); + } + } + + addTab(collapse: NzCollapseComponent) { + this.panels.push(collapse); + } + + constructor() { + } +} diff --git a/src/components/collapse/style/index.less b/src/components/collapse/style/index.less new file mode 100644 index 00000000000..10c36c02f2b --- /dev/null +++ b/src/components/collapse/style/index.less @@ -0,0 +1,124 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; + +@collapse-prefix-cls: ~"@{ant-prefix}-collapse"; + +@collapse-header-bg: @background-color-base; +@collapse-active-bg: @background-color-active; + +.collapse-close() { + .iconfont-size-under-12px(9px, 0); +} +.collapse-open() { + .iconfont-size-under-12px(9px, 90deg); +} + +.@{collapse-prefix-cls} { + background-color: @collapse-header-bg; + border-radius: @border-radius-base; + border: @border-width-base @border-style-base @border-color-base; + border-bottom: 0; + + & > &-item { + border-bottom: @border-width-base @border-style-base @border-color-base; + + &:last-child { + &, + & > .@{collapse-prefix-cls}-header { + border-radius: 0 0 @border-radius-base @border-radius-base; + } + } + + > .@{collapse-prefix-cls}-header { + line-height: 22px; + padding: 8px 0 8px 32px; + color: @heading-color; + cursor: pointer; + position: relative; + transition: all .3s; + + .arrow { + .collapse-close(); + .iconfont-mixin(); + position: absolute; + color: @text-color-secondary; + display: inline-block; + font-weight: bold; + line-height: 40px; + vertical-align: middle; + transition: transform 0.24s; + top: 0; + left: 16px; + &:before { + content: "\E61F"; + } + } + } + } + + &-anim-active { + transition: height .2s @ease-out; + } + + &-content { + overflow: hidden; + color: @text-color; + padding: 0 16px; + background-color: @component-background; + + & > &-box { + padding-top: 16px; + padding-bottom: 16px; + } + + &-inactive { + display: none; + } + } + + &-item:last-child { + > .@{collapse-prefix-cls}-content { + border-radius: 0 0 @border-radius-base @border-radius-base; + } + } + + & > &-item > &-header[aria-expanded="true"] { + .arrow { + .collapse-open(); + } + } + + &-borderless { + background-color: @component-background; + border: 0; + } + + &-borderless > &-item-active { + border: 0; + } + + &-borderless > &-item > &-content { + background-color: transparent; + border-top: @border-width-base @border-style-base @border-color-base; + } + + &-borderless > &-item > &-header { + transition: all .3s; + &:hover { + background-color: @collapse-header-bg; + } + } + + & &-item-disabled > &-header { + &, + & > .arrow { + cursor: not-allowed; + color: @disabled-color; + background-color: @disabled-bg; + } + } + + & > &-item:not(&-item-disabled) > .@{collapse-prefix-cls}-header:active { + background-color: @collapse-active-bg; + } +} diff --git a/src/components/collapse/style/patch.less b/src/components/collapse/style/patch.less new file mode 100644 index 00000000000..e48a75bedce --- /dev/null +++ b/src/components/collapse/style/patch.less @@ -0,0 +1,3 @@ +nz-collapse{ + display: block; +} diff --git a/src/components/core/animation/dropdown-animations.ts b/src/components/core/animation/dropdown-animations.ts new file mode 100755 index 00000000000..f9ceb56dfc2 --- /dev/null +++ b/src/components/core/animation/dropdown-animations.ts @@ -0,0 +1,41 @@ +import { + animate, + AnimationTriggerMetadata, + state, + style, + transition, + trigger, +} from '@angular/animations'; + +export const DropDownAnimation: AnimationTriggerMetadata = trigger('dropDownAnimation', [ + state('bottom', style({ + opacity : 1, + transform : 'scaleY(1)', + transformOrigin: '0% 0%' + })), + transition('void => bottom', [ + style({ + opacity : 0, + transform : 'scaleY(0)', + transformOrigin: '0% 0%' + }), + animate('150ms cubic-bezier(0.25, 0.8, 0.25, 1)') + ]), + state('top', style({ + opacity : 1, + transform : 'scaleY(1)', + transformOrigin: '0% 100%' + })), + transition('void => top', [ + style({ + opacity : 0, + transform : 'scaleY(0)', + transformOrigin: '0% 100%' + }), + animate('150ms cubic-bezier(0.25, 0.8, 0.25, 1)') + ]), + transition('* => void', [ + animate('250ms 100ms linear', style({ opacity: 0 })) + ]) +]); + diff --git a/src/components/core/animation/fade-animations.ts b/src/components/core/animation/fade-animations.ts new file mode 100644 index 00000000000..9dc31ca6e2b --- /dev/null +++ b/src/components/core/animation/fade-animations.ts @@ -0,0 +1,16 @@ +import { + animate, + AnimationTriggerMetadata, + state, + style, + transition, + trigger, +} from '@angular/animations'; + +export const FadeAnimation: AnimationTriggerMetadata = trigger('fadeAnimation', [ + state('void', style({ opacity: 0 })), + state('true', style({ opacity: 1 })), + state('false', style({ opacity: 0 })), + transition('* => true', animate('150ms cubic-bezier(0.0, 0.0, 0.2, 1)')), + transition('* => void', animate('150ms cubic-bezier(0.4, 0.0, 1, 1)')), +]); diff --git a/src/components/core/animation/tag-animations.ts b/src/components/core/animation/tag-animations.ts new file mode 100755 index 00000000000..322799aa6fc --- /dev/null +++ b/src/components/core/animation/tag-animations.ts @@ -0,0 +1,21 @@ +import { + animate, + AnimationTriggerMetadata, + state, + style, + transition, + trigger, +} from '@angular/animations'; + +export const TagAnimation: AnimationTriggerMetadata = trigger('tagAnimation', [ + state('*', style({ opacity: 1, transform: 'scale(1)' })), + transition('void => *', [ + style({ opacity: 0, transform: 'scale(0)' }), + animate('150ms linear') + ]), + state('void', style({ opacity: 0, transform: 'scale(0)' })), + transition('* => void', [ + style({ opacity: 1, transform: 'scale(1)' }), + animate('150ms linear') + ]) +]) diff --git a/src/components/core/floater/floater-props.ts b/src/components/core/floater/floater-props.ts new file mode 100644 index 00000000000..6f320461f25 --- /dev/null +++ b/src/components/core/floater/floater-props.ts @@ -0,0 +1,230 @@ +import { + ElementRef, + EventEmitter, + TemplateRef, + Type, + ViewContainerRef, + Injector, + Component +} from '@angular/core'; +import { + OverlayOrigin, + ConnectionPositionPair, + ConnectedPositionStrategy, + PositionStrategy, + ConnectedOverlayPositionChange, + ScrollStrategy +} from '../overlay'; +import { ComponentType, Directionality } from '@angular/cdk'; +import { Subscription } from 'rxjs/Subscription'; + +// Coerces a data-bound value (typically a string) to a boolean +function coerceBooleanProperty(value: any): boolean { + return value != null && `${value}` !== 'false'; +} + +// Default set of positions for the overlay. Follows the behavior of a dropdown +const defaultPositionList = [ + new ConnectionPositionPair( + { originX: 'start', originY: 'bottom' }, + { overlayX: 'start', overlayY: 'top' } + ), + new ConnectionPositionPair( + { originX: 'start', originY: 'top' }, + { overlayX: 'start', overlayY: 'bottom' } + ) +]; + +export type FloaterContent = TemplateRef | ComponentType; + +export type PositionStrategyType = 'connected' | 'free'; + +export type PositionStrategyOptions = ConnectedPositionOptions | FreePositionOptions; + +export type FloaterOrigin = OverlayOrigin | ElementRef; + +export interface ConnectedPositionOptions { + // Origin for the connected overlay + origin: FloaterOrigin; + + // Registered connected position pairs + positions?: ConnectionPositionPair[]; + + // The offset in pixels for the overlay connection point on the x-axis + offsetX?: number; + + // The offset in pixels for the overlay connection point on the y-axis + offsetY?: number; +} + +export interface FreePositionOptions { + [index: string]: any; // Fake + // TODO +} + +// Options for floater (NOTE: don't like ConnectedOverlayDirective's @Input, normally, these options will be only used once at the initializing time) +export class FloaterOptions { + // --------------------------------------------------------- + // | Configurations + // --------------------------------------------------------- + + // Current position strategy + positionStrategyType?: PositionStrategyType = 'connected'; + + // Position strategy options + positionStrategyOptions?: PositionStrategyOptions; + + // Used for: + // 1. The element's layout direction + // 2. The direction of the text in the overlay panel + dir?: Directionality; + + // The width of the overlay panel + width?: number | string; + + // The height of the overlay panel + height?: number | string; + + // The min width of the overlay panel + minWidth?: number | string; + + // The min height of the overlay panel + minHeight?: number | string; + + // The custom class to be set on the backdrop element + backdropClass?: string; + + // The custom class to be set on the pane element + paneClass?: string; + + // Strategy to be used when handling scroll events while the overlay is open + scrollStrategy?: ScrollStrategy; + + // Whether or not the overlay should attach a backdrop + hasBackdrop?: boolean; + + // Content to be floated + content?: FloaterContent; + + // View container that the content will appended to + viewContainerRef?: ViewContainerRef; + + // [ONLY under "content"=ComponentType] Injector for dynamic creating component + injector?: Injector; + + // [ONLY under "content"=ComponentType] Callback at the moment the component is dynamiclly created (before its ngOnInit()) + afterComponentCreated?: (instance: T) => void; + + // Whether keep overlay existing except call detach() or destroy() manually + persist? = false; + + // --------------------------------------------------------- + // | Emitters + // --------------------------------------------------------- + + // Event emitted when the backdrop is clicked + backdropClick? = new EventEmitter(); + + // Event emitted when the position has changed + positionChange? = new EventEmitter(); + + // Event emitted when the overlay has been attached + attach? = new EventEmitter(); + + // Event emitted when the overlay has been detached + detach? = new EventEmitter(); + + // --------------------------------------------------------- + // | Event listener (against to Emitters, normally used when provide floater as service) + // --------------------------------------------------------- + onBackdropClick?: () => void; + onPositionChange?: (change: ConnectedOverlayPositionChange) => void; + onAttach?: () => void; + onDetach?: () => void; +} + +// Inner properties manager for floater +export class FloaterProps extends FloaterOptions { + private _hasBackdrop = false; + private _position: PositionStrategy; + private _emitterSubscriptions: Subscription[] = []; + + get hasBackdrop() { + return this._hasBackdrop; + } + set hasBackdrop(value: any) { + this._hasBackdrop = coerceBooleanProperty(value); + } + + // Options validator + constructor(options: FloaterOptions) { + super(); + + // Validating + this._validateOptions(options); + + // Merge options + Object.assign(this, options); + + // Default values + this._initDefaultOptions(); + + // Event listeners + this._initEventListeners(); + } + + private _validateOptions(options: FloaterOptions) { + if (!options.content) { + throw new Error('[FloaterOptions] "content" is required.'); + } + if (options.content instanceof TemplateRef && !options.viewContainerRef) { + throw new Error('[FloaterOptions] "viewContainerRef" is required for "content" of TemplateRef.'); + } + if (options.positionStrategyType === 'connected' && !options.positionStrategyOptions) { + throw new Error('[FloaterOptions] "positionStrategyOptions" can\'t be empty when position strategy type is "connected".'); + } + } + + private _initDefaultOptions() { + const strategyOptions = this.positionStrategyOptions as ConnectedPositionOptions; + if (this.positionStrategyType === 'connected') { + if (!strategyOptions.positions || !strategyOptions.positions.length) { + strategyOptions.positions = defaultPositionList; + } + } + } + + private _initEventListeners() { + const subscriptions = this._emitterSubscriptions; + if (this.onBackdropClick) { + subscriptions.push(this.backdropClick.subscribe(this.onBackdropClick)); + } + if (this.onPositionChange) { + subscriptions.push(this.positionChange.subscribe(this.onPositionChange)); + } + if (this.onAttach) { + subscriptions.push(this.attach.subscribe(this.onAttach)); + } + if (this.onDetach) { + subscriptions.push(this.detach.subscribe(this.onDetach)); + } + } + + // --------------------------------------------------------- + // | Public methods + // --------------------------------------------------------- + + // Set current position strategy + setPositionStrategy(position: PositionStrategy) { + this._position = position; + } + getPositionStrategy(): PositionStrategy { + return this._position; + } + + // Destory all resources + destroy() { + this._emitterSubscriptions.forEach(subscription => subscription.unsubscribe()); + this._emitterSubscriptions = null; + } +} diff --git a/src/components/core/floater/floater.service.ts b/src/components/core/floater/floater.service.ts new file mode 100644 index 00000000000..401f1a028f2 --- /dev/null +++ b/src/components/core/floater/floater.service.ts @@ -0,0 +1,79 @@ +import { + Injectable, + Inject, + Injector, + ViewContainerRef, + Provider, + Optional, + SkipSelf +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { ComponentType } from '@angular/cdk'; +import { + Floater +} from './floater'; +import { + FloaterOptions, + FloaterContent, + ConnectedPositionOptions, + FreePositionOptions, + PositionStrategyOptions, + PositionStrategyType, +} from './floater-props'; +import { Overlay } from '../overlay'; + +Injectable() + +export class FloaterService { + + constructor(private _overlay: Overlay, @Inject(DOCUMENT) private _document: Document) { + // console.log('[FloaterService] constructed once.'); + } + + create(strategyType: PositionStrategyType, + content: FloaterContent, + viewContainerRef?: ViewContainerRef, + strategyOptions?: PositionStrategyOptions, + options?: FloaterOptions) { + options = Object.assign(options || {}, { + content : content, + viewContainerRef : viewContainerRef, + positionStrategyType : strategyType, + positionStrategyOptions: strategyOptions + }); + return new Floater(this._overlay, this._document, options); + } + + createConnected(content: FloaterContent, viewContainerRef?: ViewContainerRef, strategyOptions?: ConnectedPositionOptions, options?: FloaterOptions) { + return this.create('connected', content, viewContainerRef, strategyOptions, options); + } + + createFree(content: FloaterContent, viewContainerRef?: ViewContainerRef, strategyOptions?: FreePositionOptions, options?: FloaterOptions) { + return this.create('free', content, viewContainerRef, strategyOptions, options); + } + + /** + * Persistently create/initialize a Component and append it's DOM to body(under overlay) + * NOTE: the attaching operation is persistent, means that it is no methods to detach the component while attached (no relative resources can be released), SO take care of using it! + * @param component Component class + * @param viewContainerRef Container reference that component will created and append to + * @param injector Injector that will be used while creating component dynamically + * @return Instance of the component + */ + persistAttachComponent(component: ComponentType, viewContainerRef?: ViewContainerRef, injector?: Injector) { + return this.createFree(component, viewContainerRef, null, { + injector: injector, + persist : true + }).attach().getComponentInstance(); + } +} + +export function FLOATER_SERVICE_PROVIDER_FACTORY(overlay, doc, floaterService) { + return floaterService || new FloaterService(overlay, doc); +} + +export const FLOATER_SERVICE_PROVIDER: Provider = { + provide : FloaterService, + useFactory: FLOATER_SERVICE_PROVIDER_FACTORY, + deps : [ Overlay, DOCUMENT, [ new Optional(), new SkipSelf(), FloaterService ] ] +}; diff --git a/src/components/core/floater/floater.ts b/src/components/core/floater/floater.ts new file mode 100644 index 00000000000..46111f8f914 --- /dev/null +++ b/src/components/core/floater/floater.ts @@ -0,0 +1,304 @@ +import { TemplateRef, ComponentRef, Renderer2 } from '@angular/core'; +import { + OverlayRef, + Overlay, + OverlayState, + OverlayOrigin, + ConnectedPositionStrategy, + PositionStrategy +} from '../overlay'; +import { + TemplatePortal, + ComponentPortal, + Portal, + Direction, + ESCAPE +} from '@angular/cdk'; +import { Subscription } from 'rxjs/Subscription'; +import { + FloaterProps, + FloaterOptions, + ConnectedPositionOptions, +} from './floater-props'; + +/** + * Floater object to manage overlay (Enhanced verion of ConnectedOverlayDirective) + * NOTE: don't like ConnectedOverlayDirective, Floater has no dependencies with directive (such as Component, Directive ...) + * but this will means that you should destroy it and incidental resources manually using destroy() method! + */ +export class Floater { + private _props: FloaterProps; + private _overlayRef: OverlayRef; + private _attachedResult: ComponentRef | Map; + private _contentPortal: Portal; + private _backdropSubscription: Subscription | null; + private _positionSubscription: Subscription; + private _escapeListener: Function; + private _stateAttached = false; // State to prevent duplicated attaching/detaching + private _stateDestroyed = false; // State to prevent duplicated destroying + + // The associated overlay reference + get overlayRef(): OverlayRef { + return this._overlayRef; + } + + // The attached component ref or template locals(haven't done yet) + get attachedResult() { + return this._attachedResult; + } + + // The element's layout direction && The direction of the text in the overlay panel + get dir(): Direction { + return this._props.dir ? this._props.dir.value : 'ltr'; + } + + constructor(private _overlay: Overlay, + private _renderer: Renderer2 | Document, + options: FloaterOptions) { + + this._props = new FloaterProps(options); + const { content, viewContainerRef, injector } = this._props; + + if (content instanceof TemplateRef) { + this._contentPortal = new TemplatePortal(content, viewContainerRef); + } else if (typeof content === 'function') { // Assume all functions stand as component + this._contentPortal = new ComponentPortal(content, viewContainerRef, injector); + } else { + throw new Error('[Floater] Not support "content" type.'); + } + } + + // Get attached component's instance + getComponentInstance() { + if (!this._isCreatedByComponent()) { + throw new Error(`Cant't get ComponentRef when created without ComponentType (Should fill the "content" option with value of ComponentType)`); + } + return (>this.attachedResult).instance; + } + + attach(): this { + if (!this._stateAttached) { + this._stateAttached = true; + this._attachOverlay(); + } + return this; + } + + detach(): this { + if (this._stateAttached) { + this._stateAttached = false; + this._detachOverlay(); + } + return this; + } + + destroy(): this { + if (!this._stateDestroyed) { + this._stateDestroyed = true; + this._destroyOverlay(); + } + return this; + } + + // Attaches the overlay and subscribes to backdrop clicks if backdrop exists + private _attachOverlay() { + // console.log('[Floater] Attach overlay'); + + const { hasBackdrop, backdropClick, attach, persist } = this._props; + if (!this._overlayRef) { + this._createOverlay(); + } + + const position = this._props.getPositionStrategy(); + if (position instanceof ConnectedPositionStrategy) { + position.withDirection(this.dir); + } + + this._overlayRef.getState().direction = this.dir; + if (!persist) { + this._initEscapeListener(); + } + + if (!this._overlayRef.hasAttached()) { + this._attachedResult = this._overlayRef.attach(this._contentPortal); + if (this._isCreatedByComponent()) { + this._handleAttachedComponent(this._attachedResult as ComponentRef); + } + if (attach) { + attach.emit(); + } + } + + if (hasBackdrop && backdropClick) { + this._backdropSubscription = this._overlayRef.backdropClick().subscribe(() => { + backdropClick.emit(); + }); + } + } + + // Detaches the overlay and unsubscribes to backdrop clicks if backdrop exists + private _detachOverlay() { + // console.log('[Floater] Detach overlay'); + + const { detach } = this._props; + if (this._overlayRef) { + this._overlayRef.detach(); + if (detach) { + detach.emit(); + } + } + + if (this._backdropSubscription) { + this._backdropSubscription.unsubscribe(); + this._backdropSubscription = null; + } + + if (this._escapeListener) { + this._escapeListener(); + this._escapeListener = null; + } + } + + // Destroys the overlay created by this directive + private _destroyOverlay() { + // console.log('[Floater] Destroy overlay'); + + if (this._overlayRef) { + this.overlayRef.dispose(); + } + + if (this._backdropSubscription) { + this._backdropSubscription.unsubscribe(); + } + + if (this._positionSubscription) { + this._positionSubscription.unsubscribe(); + } + + if (this._escapeListener) { + this._escapeListener(); + } + + this._props.destroy(); + } + + // Creates an overlay + private _createOverlay() { + this._overlayRef = this._overlay.create(this._buildConfig(), this._props.paneClass); + } + + // Builds the overlay config based on the directive's inputs + private _buildConfig(): OverlayState { + const overlayConfig = new OverlayState(); + const { width, height, minWidth, minHeight, hasBackdrop, backdropClass } = this._props; + + if (width || width === 0) { + overlayConfig.width = width; + } + if (height || height === 0) { + overlayConfig.height = height; + } + if (minWidth || minWidth === 0) { + overlayConfig.minWidth = minWidth; + } + if (minHeight || minHeight === 0) { + overlayConfig.minHeight = minHeight; + } + + overlayConfig.hasBackdrop = hasBackdrop; + + if (backdropClass) { + overlayConfig.backdropClass = backdropClass; + } + + const strategy = this._createPositionStrategy(); + if (strategy) { + this._props.setPositionStrategy(strategy); + overlayConfig.positionStrategy = strategy; + // Use noop scroll strategy by default + overlayConfig.scrollStrategy = + this._props.scrollStrategy ? this._props.scrollStrategy : this._overlay.scrollStrategies.noop(); + } + + return overlayConfig; + } + + // Returns the position strategy of the overlay to be set on the overlay config + private _createPositionStrategy(): PositionStrategy { + const { positionStrategyType: strategyType, positionStrategyOptions: strategyOptions } = this._props; + + let strategy = null; + if (strategyType === 'connected') { // Using ConnectedPositionStrategy + const { positions, origin, offsetX, offsetY } = strategyOptions as ConnectedPositionOptions; + const pos = positions[0]; + const originPoint = { originX: pos.originX, originY: pos.originY }; + const overlayPoint = { overlayX: pos.overlayX, overlayY: pos.overlayY }; + const elementRef = origin instanceof OverlayOrigin ? origin.elementRef : origin; + strategy = this._overlay.position() + .connectedTo(elementRef, originPoint, overlayPoint) + .withOffsetX(offsetX || 0) + .withOffsetY(offsetY || 0); + + this._handlePositionChanges(strategy); + + if (!this._props.scrollStrategy) { // Default scroll strategy for ConnectedPositionStrategy + this._props.scrollStrategy = this._overlay.scrollStrategies.reposition(); + } + } else if (strategyType === 'free') { // Using FreePositionStrategy + strategy = this._overlay.position().free(); + } + + return strategy; + } + + // For ConnectedPositionStrategy ONLY + private _handlePositionChanges(strategy: ConnectedPositionStrategy): void { + const { positionStrategyOptions: strategyOptions, positionChange } = this._props; + const positions = (strategyOptions).positions; + for (let i = 1; i < positions.length; i++) { + strategy.withFallbackPosition( + { originX: positions[i].originX, originY: positions[i].originY }, + { overlayX: positions[i].overlayX, overlayY: positions[i].overlayY } + ); + } + + if (positionChange) { + this._positionSubscription = + strategy.onPositionChange.subscribe(pos => positionChange.emit(pos)); + } + } + + // Return if the floater is initialized by dynamic Component + private _isCreatedByComponent() { + return this._contentPortal instanceof ComponentPortal; + } + + // Other works after creating component + private _handleAttachedComponent(componentRef: ComponentRef) { + const instance = componentRef.instance, afterComponentCreated = this._props.afterComponentCreated; + + if (afterComponentCreated) { + afterComponentCreated(instance); + } + } + + // Sets the event listener that closes the overlay when pressing Escape + private _initEscapeListener() { + const listener = (event: KeyboardEvent) => { + if (event.keyCode === ESCAPE) { + this._detachOverlay(); + } + }; + + if (this._renderer instanceof Renderer2) { + this._escapeListener = this._renderer.listen('document', 'keydown', listener); + } else if (this._renderer instanceof Document) { + // console.log('[Floater/_initEscapeListener]use document', this._renderer); + this._renderer.addEventListener('keydown', listener); + this._escapeListener = () => { + (this._renderer).removeEventListener('keydown', listener); + }; + } + } + +} diff --git a/src/components/core/floater/index.ts b/src/components/core/floater/index.ts new file mode 100644 index 00000000000..e77c9381b43 --- /dev/null +++ b/src/components/core/floater/index.ts @@ -0,0 +1,17 @@ +import { NgModule, Provider } from '@angular/core'; +import { OverlayModule } from '../overlay'; +import { FLOATER_SERVICE_PROVIDER } from './floater.service'; + +const providers: Provider[] = [ + FLOATER_SERVICE_PROVIDER +]; + +@NgModule({ + imports: [ OverlayModule ], + providers: providers +}) +export class FloaterModule {} + +export * from './floater-props'; +export { Floater } from './floater'; +export { FloaterService } from './floater.service'; diff --git a/src/components/core/overlay/fullscreen-overlay-container.ts b/src/components/core/overlay/fullscreen-overlay-container.ts new file mode 100755 index 00000000000..5c9a34e8436 --- /dev/null +++ b/src/components/core/overlay/fullscreen-overlay-container.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injectable} from '@angular/core'; +import {OverlayContainer} from './overlay-container'; + +/** + * The FullscreenOverlayContainer is the alternative to OverlayContainer + * that supports correct displaying of overlay elements in Fullscreen mode + * https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullScreen + * It should be provided in the root component that way: + * providers: [ + * {provide: OverlayContainer, useClass: FullscreenOverlayContainer} + * ], + */ +@Injectable() +export class FullscreenOverlayContainer extends OverlayContainer { + protected _createContainer(): void { + super._createContainer(); + this._adjustParentForFullscreenChange(); + this._addFullscreenChangeListener(() => this._adjustParentForFullscreenChange()); + } + + private _adjustParentForFullscreenChange(): void { + if (!this._containerElement) { + return; + } + const fullscreenElement = this.getFullscreenElement(); + const parent = fullscreenElement || document.body; + parent.appendChild(this._containerElement); + } + + private _addFullscreenChangeListener(fn: () => void) { + if (document.fullscreenEnabled) { + document.addEventListener('fullscreenchange', fn); + } else if (document.webkitFullscreenEnabled) { + document.addEventListener('webkitfullscreenchange', fn); + } else if ((document as any).mozFullScreenEnabled) { + document.addEventListener('mozfullscreenchange', fn); + } else if ((document as any).msFullscreenEnabled) { + document.addEventListener('MSFullscreenChange', fn); + } + } + + /** + * When the page is put into fullscreen mode, a specific element is specified. + * Only that element and its children are visible when in fullscreen mode. + */ + getFullscreenElement(): Element { + return document.fullscreenElement || + document.webkitFullscreenElement || + (document as any).mozFullScreenElement || + (document as any).msFullscreenElement || + null; + } +} diff --git a/src/components/core/overlay/generic-component-type.ts b/src/components/core/overlay/generic-component-type.ts new file mode 100755 index 00000000000..e4c7688dba2 --- /dev/null +++ b/src/components/core/overlay/generic-component-type.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export interface ComponentType { + new (...args: any[]): T; +} diff --git a/src/components/core/overlay/index.ts b/src/components/core/overlay/index.ts new file mode 100755 index 00000000000..0aaf01fbcdc --- /dev/null +++ b/src/components/core/overlay/index.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {NgModule, Provider} from '@angular/core'; +import {Overlay} from './overlay'; +import {ScrollDispatchModule} from './scroll/index'; +import {ConnectedOverlayDirective, OverlayOrigin} from './overlay-directives'; +import {OverlayPositionBuilder} from './position/overlay-position-builder'; +import {VIEWPORT_RULER_PROVIDER} from './position/viewport-ruler'; +import {OVERLAY_CONTAINER_PROVIDER} from './overlay-container'; + + +export const OVERLAY_PROVIDERS: Provider[] = [ + Overlay, + OverlayPositionBuilder, + VIEWPORT_RULER_PROVIDER, + OVERLAY_CONTAINER_PROVIDER, +]; + +@NgModule({ + imports: [ScrollDispatchModule], + exports: [ConnectedOverlayDirective, OverlayOrigin, ScrollDispatchModule], + declarations: [ConnectedOverlayDirective, OverlayOrigin], + providers: [OVERLAY_PROVIDERS], +}) +export class OverlayModule {} + + +export {Overlay} from './overlay'; +export {OverlayContainer} from './overlay-container'; +export {FullscreenOverlayContainer} from './fullscreen-overlay-container'; +export {OverlayRef} from './overlay-ref'; +export {OverlayState} from './overlay-state'; +export {ConnectedOverlayDirective, OverlayOrigin} from './overlay-directives'; +export {ViewportRuler} from './position/viewport-ruler'; + +export * from './position/connected-position'; +export * from './scroll/index'; + +// Export pre-defined position strategies and interface to build custom ones. +export {PositionStrategy} from './position/position-strategy'; +export {GlobalPositionStrategy} from './position/global-position-strategy'; +export {ConnectedPositionStrategy} from './position/connected-position-strategy'; diff --git a/src/components/core/overlay/overlay-container.ts b/src/components/core/overlay/overlay-container.ts new file mode 100755 index 00000000000..23980e2308f --- /dev/null +++ b/src/components/core/overlay/overlay-container.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injectable, Optional, SkipSelf} from '@angular/core'; + + +/** + * The OverlayContainer is the container in which all overlays will load. + * It should be provided in the root component to ensure it is properly shared. + */ +@Injectable() +export class OverlayContainer { + protected _containerElement: HTMLElement; + + private _themeClass: string; + + /** + * Base theme to be applied to all overlay-based components. + */ + get themeClass(): string { return this._themeClass; } + set themeClass(value: string) { + if (this._containerElement) { + this._containerElement.classList.remove(this._themeClass); + + if (value) { + this._containerElement.classList.add(value); + } + } + + this._themeClass = value; + } + + /** + * This method returns the overlay container element. It will lazily + * create the element the first time it is called to facilitate using + * the container in non-browser environments. + * @returns the container element + */ + getContainerElement(): HTMLElement { + if (!this._containerElement) { this._createContainer(); } + return this._containerElement; + } + + /** + * Create the overlay container element, which is simply a div + * with the 'cdk-overlay-container' class on the document body. + */ + protected _createContainer(): void { + const container = document.createElement('div'); + container.classList.add('nz-overlay-container'); + + if (this._themeClass) { + container.classList.add(this._themeClass); + } + + document.body.appendChild(container); + this._containerElement = container; + } +} + +export function OVERLAY_CONTAINER_PROVIDER_FACTORY(parentContainer: OverlayContainer) { + return parentContainer || new OverlayContainer(); +} + +export const OVERLAY_CONTAINER_PROVIDER = { + // If there is already an OverlayContainer available, use that. Otherwise, provide a new one. + provide: OverlayContainer, + deps: [[new Optional(), new SkipSelf(), OverlayContainer]], + useFactory: OVERLAY_CONTAINER_PROVIDER_FACTORY +}; diff --git a/src/components/core/overlay/overlay-directives.ts b/src/components/core/overlay/overlay-directives.ts new file mode 100755 index 00000000000..f6bb4a218c4 --- /dev/null +++ b/src/components/core/overlay/overlay-directives.ts @@ -0,0 +1,329 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Directive, + EventEmitter, + TemplateRef, + ViewContainerRef, + Optional, + Input, + OnDestroy, + Output, + ElementRef, + Renderer2, + OnChanges, + SimpleChanges, +} from '@angular/core'; +import { Overlay } from './overlay'; +import { OverlayRef } from './overlay-ref'; +import { TemplatePortal } from '@angular/cdk'; +import { OverlayState } from './overlay-state'; +import { + ConnectionPositionPair, + ConnectedOverlayPositionChange +} from './position/connected-position'; +import { ConnectedPositionStrategy } from './position/connected-position-strategy'; +import { Directionality, Direction } from '@angular/cdk'; +import { ScrollStrategy } from './scroll/scroll-strategy'; +import { ESCAPE } from '@angular/cdk'; +import { Subscription } from 'rxjs/Subscription'; +/** Coerces a data-bound value (typically a string) to a boolean. */ +export function coerceBooleanProperty(value: any): boolean { + return value != null && `${value}` !== 'false'; +} + + +/** Default set of positions for the overlay. Follows the behavior of a dropdown. */ +const defaultPositionList = [ + new ConnectionPositionPair( + { originX: 'start', originY: 'bottom' }, + { overlayX: 'start', overlayY: 'top' }), + new ConnectionPositionPair( + { originX: 'start', originY: 'top' }, + { overlayX: 'start', overlayY: 'bottom' }), +]; + + +/** + * Directive applied to an element to make it usable as an origin for an Overlay using a + * ConnectedPositionStrategy. + */ +@Directive({ + selector: '[nz-overlay-origin]', + exportAs: 'nzOverlayOrigin', +}) +export class OverlayOrigin { + constructor(public elementRef: ElementRef) { + } +} + + +/** + * Directive to facilitate declarative creation of an Overlay using a ConnectedPositionStrategy. + */ +@Directive({ + selector: '[nz-connected-overlay]', + exportAs: 'nzConnectedOverlay' +}) +export class ConnectedOverlayDirective implements OnDestroy, OnChanges { + private _overlayRef: OverlayRef; + private _templatePortal: TemplatePortal; + private _hasBackdrop = false; + private _backdropSubscription: Subscription | null; + private _positionSubscription: Subscription; + private _offsetX = 0; + private _offsetY = 0; + private _position: ConnectedPositionStrategy; + private _escapeListener: Function; + + /** Origin for the connected overlay. */ + @Input() origin: OverlayOrigin; + + /** Registered connected position pairs. */ + @Input() positions: ConnectionPositionPair[]; + + /** The offset in pixels for the overlay connection point on the x-axis */ + @Input() + get offsetX(): number { + return this._offsetX; + } + + set offsetX(offsetX: number) { + this._offsetX = offsetX; + if (this._position) { + this._position.withOffsetX(offsetX); + } + } + + /** The offset in pixels for the overlay connection point on the y-axis */ + @Input() + get offsetY() { + return this._offsetY; + } + + set offsetY(offsetY: number) { + this._offsetY = offsetY; + if (this._position) { + this._position.withOffsetY(offsetY); + } + } + + /** The width of the overlay panel. */ + @Input() width: number | string; + + /** The height of the overlay panel. */ + @Input() height: number | string; + + /** The min width of the overlay panel. */ + @Input() minWidth: number | string; + + /** The min height of the overlay panel. */ + @Input() minHeight: number | string; + + /** The custom class to be set on the backdrop element. */ + @Input() backdropClass: string; + + /** The custom class to be set on the pane element. */ + @Input() paneClass: string; + + /** Strategy to be used when handling scroll events while the overlay is open. */ + @Input() scrollStrategy: ScrollStrategy = this._overlay.scrollStrategies.reposition(); + + /** Whether the overlay is open. */ + @Input() open = false; + + /** Whether or not the overlay should attach a backdrop. */ + @Input() + get hasBackdrop() { + return this._hasBackdrop; + } + + set hasBackdrop(value: any) { + this._hasBackdrop = coerceBooleanProperty(value); + } + + /** Event emitted when the backdrop is clicked. */ + @Output() backdropClick = new EventEmitter(); + + /** Event emitted when the position has changed. */ + @Output() positionChange = new EventEmitter(); + + /** Event emitted when the overlay has been attached. */ + @Output() attach = new EventEmitter(); + + /** Event emitted when the overlay has been detached. */ + @Output() detach = new EventEmitter(); + + // TODO(jelbourn): inputs for size, scroll behavior, animation, etc. + + constructor(private _overlay: Overlay, + private _renderer: Renderer2, + templateRef: TemplateRef, + viewContainerRef: ViewContainerRef, + @Optional() private _dir: Directionality) { + this._templatePortal = new TemplatePortal(templateRef, viewContainerRef); + } + + /** The associated overlay reference. */ + get overlayRef(): OverlayRef { + return this._overlayRef; + } + + /** The element's layout direction. */ + get dir(): Direction { + return this._dir ? this._dir.value : 'ltr'; + } + + ngOnDestroy() { + this._destroyOverlay(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes[ 'open' ]) { + this.open ? this._attachOverlay() : this._detachOverlay(); + } + } + + /** Creates an overlay */ + private _createOverlay() { + if (!this.positions || !this.positions.length) { + this.positions = defaultPositionList; + } + + this._overlayRef = this._overlay.create(this._buildConfig(), this.paneClass); + } + + /** Builds the overlay config based on the directive's inputs */ + private _buildConfig(): OverlayState { + const overlayConfig = new OverlayState(); + + if (this.width || this.width === 0) { + overlayConfig.width = this.width; + } + + if (this.height || this.height === 0) { + overlayConfig.height = this.height; + } + + if (this.minWidth || this.minWidth === 0) { + overlayConfig.minWidth = this.minWidth; + } + + if (this.minHeight || this.minHeight === 0) { + overlayConfig.minHeight = this.minHeight; + } + + overlayConfig.hasBackdrop = this.hasBackdrop; + + if (this.backdropClass) { + overlayConfig.backdropClass = this.backdropClass; + } + + this._position = this._createPositionStrategy() as ConnectedPositionStrategy; + overlayConfig.positionStrategy = this._position; + overlayConfig.scrollStrategy = this.scrollStrategy; + + return overlayConfig; + } + + /** Returns the position strategy of the overlay to be set on the overlay config */ + private _createPositionStrategy(): ConnectedPositionStrategy { + const pos = this.positions[ 0 ]; + const originPoint = { originX: pos.originX, originY: pos.originY }; + const overlayPoint = { overlayX: pos.overlayX, overlayY: pos.overlayY }; + + const strategy = this._overlay.position() + .connectedTo(this.origin.elementRef, originPoint, overlayPoint) + .withOffsetX(this.offsetX) + .withOffsetY(this.offsetY); + + this._handlePositionChanges(strategy); + + return strategy; + } + + private _handlePositionChanges(strategy: ConnectedPositionStrategy): void { + for (let i = 1; i < this.positions.length; i++) { + strategy.withFallbackPosition( + { originX: this.positions[ i ].originX, originY: this.positions[ i ].originY }, + { overlayX: this.positions[ i ].overlayX, overlayY: this.positions[ i ].overlayY } + ); + } + + this._positionSubscription = + strategy.onPositionChange.subscribe(pos => this.positionChange.emit(pos)); + } + + /** Attaches the overlay and subscribes to backdrop clicks if backdrop exists */ + private _attachOverlay() { + if (!this._overlayRef) { + this._createOverlay(); + } + + this._position.withDirection(this.dir); + this._overlayRef.getState().direction = this.dir; + this._initEscapeListener(); + + if (!this._overlayRef.hasAttached()) { + this._overlayRef.attach(this._templatePortal); + this.attach.emit(); + } + + if (this.hasBackdrop) { + this._backdropSubscription = this._overlayRef.backdropClick().subscribe(() => { + this.backdropClick.emit(); + }); + } + } + + /** Detaches the overlay and unsubscribes to backdrop clicks if backdrop exists */ + private _detachOverlay() { + if (this._overlayRef) { + this._overlayRef.detach(); + this.detach.emit(); + } + + if (this._backdropSubscription) { + this._backdropSubscription.unsubscribe(); + this._backdropSubscription = null; + } + + if (this._escapeListener) { + this._escapeListener(); + } + } + + /** Destroys the overlay created by this directive. */ + private _destroyOverlay() { + if (this._overlayRef) { + this._overlayRef.dispose(); + } + + if (this._backdropSubscription) { + this._backdropSubscription.unsubscribe(); + } + + if (this._positionSubscription) { + this._positionSubscription.unsubscribe(); + } + + if (this._escapeListener) { + this._escapeListener(); + } + } + + /** Sets the event listener that closes the overlay when pressing Escape. */ + private _initEscapeListener() { + this._escapeListener = this._renderer.listen('document', 'keydown', (event: KeyboardEvent) => { + if (event.keyCode === ESCAPE) { + this._detachOverlay(); + } + }); + } +} diff --git a/src/components/core/overlay/overlay-position-map.ts b/src/components/core/overlay/overlay-position-map.ts new file mode 100644 index 00000000000..0eeedd9a36e --- /dev/null +++ b/src/components/core/overlay/overlay-position-map.ts @@ -0,0 +1,125 @@ +import { ConnectionPositionPair } from './index'; + +export const POSITION_MAP = { + 'top' : { + originX : 'center', + originY : 'top', + overlayX: 'center', + overlayY: 'bottom' + }, + 'topCenter' : { + originX : 'center', + originY : 'top', + overlayX: 'center', + overlayY: 'bottom' + }, + 'topLeft' : { + originX : 'start', + originY : 'top', + overlayX: 'start', + overlayY: 'bottom' + }, + 'topRight' : { + originX : 'end', + originY : 'top', + overlayX: 'end', + overlayY: 'bottom' + }, + 'right' : { + originX : 'end', + originY : 'center', + overlayX: 'start', + overlayY: 'center', + }, + 'rightTop' : { + originX : 'end', + originY : 'top', + overlayX: 'start', + overlayY: 'top', + }, + 'rightBottom' : { + originX : 'end', + originY : 'bottom', + overlayX: 'start', + overlayY: 'bottom', + }, + 'bottom' : { + originX : 'center', + originY : 'bottom', + overlayX: 'center', + overlayY: 'top', + }, + 'bottomCenter': { + originX : 'center', + originY : 'bottom', + overlayX: 'center', + overlayY: 'top', + }, + 'bottomLeft' : { + originX : 'start', + originY : 'bottom', + overlayX: 'start', + overlayY: 'top', + }, + 'bottomRight' : { + originX : 'end', + originY : 'bottom', + overlayX: 'end', + overlayY: 'top', + }, + 'left' : { + originX : 'start', + originY : 'center', + overlayX: 'end', + overlayY: 'center', + }, + 'leftTop' : { + originX : 'start', + originY : 'top', + overlayX: 'end', + overlayY: 'top', + }, + 'leftBottom' : { + originX : 'start', + originY : 'bottom', + overlayX: 'end', + overlayY: 'bottom', + }, +}; +export const DEFAULT_4_POSITIONS = _objectValues([ POSITION_MAP[ 'top' ], POSITION_MAP[ 'right' ], POSITION_MAP[ 'bottom' ], POSITION_MAP[ 'left' ] ]); +export const DEFAULT_DROPDOWN_POSITIONS = _objectValues([ POSITION_MAP[ 'bottomLeft' ], POSITION_MAP[ 'topLeft' ] ]); +export const DEFAULT_DATEPICKER_POSITIONS = [ + { + originX : 'start', + originY : 'top', + overlayX: 'start', + overlayY: 'top', + }, + { + originX : 'start', + originY : 'bottom', + overlayX: 'start', + overlayY: 'bottom', + } +] as ConnectionPositionPair[]; + +function arrayMap(array, iteratee) { + let index = -1; + const length = array == null ? 0 : array.length, + result = Array(length); + + while (++index < length) { + result[ index ] = iteratee(array[ index ], index, array); + } + return result; +} + +function baseValues(object, props) { + return arrayMap(props, function (key) { + return object[ key ]; + }); +} + +function _objectValues(object) { + return object == null ? [] : baseValues(object, Object.keys(object)); +} diff --git a/src/components/core/overlay/overlay-ref.ts b/src/components/core/overlay/overlay-ref.ts new file mode 100755 index 00000000000..c71e366cfe3 --- /dev/null +++ b/src/components/core/overlay/overlay-ref.ts @@ -0,0 +1,264 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgZone} from '@angular/core'; +import {PortalHost, Portal} from '@angular/cdk'; +import {OverlayState} from './overlay-state'; +import {ScrollStrategy} from './scroll/scroll-strategy'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; + + +/** + * Reference to an overlay that has been created with the Overlay service. + * Used to manipulate or dispose of said overlay. + */ +export class OverlayRef implements PortalHost { + private _backdropElement: HTMLElement | null = null; + private _backdropClick: Subject = new Subject(); + private _attachments = new Subject(); + private _detachments = new Subject(); + + constructor( + private _portalHost: PortalHost, + private _pane: HTMLElement, + private _state: OverlayState, + private _scrollStrategy: ScrollStrategy, + private _ngZone: NgZone) { + + _scrollStrategy.attach(this); + } + + /** The overlay's HTML element */ + get overlayElement(): HTMLElement { + return this._pane; + } + + /** + * Attaches the overlay to a portal instance and adds the backdrop. + * @param portal Portal instance to which to attach the overlay. + * @returns The portal attachment result. + */ + attach(portal: Portal): any { + const attachResult = this._portalHost.attach(portal); + + // Update the pane element with the given state configuration. + this._updateStackingOrder(); + this.updateSize(); + this.updateDirection(); + this.updatePosition(); + this._scrollStrategy.enable(); + + // Enable pointer events for the overlay pane element. + this._togglePointerEvents(true); + + if (this._state.hasBackdrop) { + this._attachBackdrop(); + } + + if (this._state.panelClass) { + this._pane.classList.add(this._state.panelClass); + } + + // Only emit the `attachments` event once all other setup is done. + this._attachments.next(); + + return attachResult; + } + + /** + * Detaches an overlay from a portal. + * @returns Resolves when the overlay has been detached. + */ + detach(): Promise { + this.detachBackdrop(); + + // When the overlay is detached, the pane element should disable pointer events. + // This is necessary because otherwise the pane element will cover the page and disable + // pointer events therefore. Depends on the position strategy and the applied pane boundaries. + this._togglePointerEvents(false); + this._scrollStrategy.disable(); + + const detachmentResult = this._portalHost.detach(); + + // Only emit after everything is detached. + this._detachments.next(); + + return detachmentResult; + } + + /** + * Cleans up the overlay from the DOM. + */ + dispose(): void { + if (this._state.positionStrategy) { + this._state.positionStrategy.dispose(); + } + + if (this._scrollStrategy) { + this._scrollStrategy.disable(); + } + + this.detachBackdrop(); + this._portalHost.dispose(); + this._attachments.complete(); + this._backdropClick.complete(); + this._detachments.next(); + this._detachments.complete(); + } + + /** + * Checks whether the overlay has been attached. + */ + hasAttached(): boolean { + return this._portalHost.hasAttached(); + } + + /** + * Returns an observable that emits when the backdrop has been clicked. + */ + backdropClick(): Observable { + return this._backdropClick.asObservable(); + } + + /** Returns an observable that emits when the overlay has been attached. */ + attachments(): Observable { + return this._attachments.asObservable(); + } + + /** Returns an observable that emits when the overlay has been detached. */ + detachments(): Observable { + return this._detachments.asObservable(); + } + + /** + * Gets the current state config of the overlay. + */ + getState(): OverlayState { + return this._state; + } + + /** Updates the position of the overlay based on the position strategy. */ + updatePosition() { + if (this._state.positionStrategy) { + this._state.positionStrategy.apply(this._pane); + } + } + + /** Updates the text direction of the overlay panel. */ + private updateDirection() { + this._pane.setAttribute('dir', this._state.direction); + } + + + /** Updates the size of the overlay based on the overlay config. */ + updateSize() { + if (this._state.width || this._state.width === 0) { + this._pane.style.width = formatCssUnit(this._state.width); + } + + if (this._state.height || this._state.height === 0) { + this._pane.style.height = formatCssUnit(this._state.height); + } + + if (this._state.minWidth || this._state.minWidth === 0) { + this._pane.style.minWidth = formatCssUnit(this._state.minWidth); + } + + if (this._state.minHeight || this._state.minHeight === 0) { + this._pane.style.minHeight = formatCssUnit(this._state.minHeight); + } + } + + /** Toggles the pointer events for the overlay pane element. */ + private _togglePointerEvents(enablePointer: boolean) { + this._pane.style.pointerEvents = enablePointer ? 'auto' : 'none'; + } + + /** Attaches a backdrop for this overlay. */ + private _attachBackdrop() { + this._backdropElement = document.createElement('div'); + this._backdropElement.classList.add('nz-overlay-backdrop'); + + if (this._state.backdropClass) { + this._backdropElement.classList.add(this._state.backdropClass); + } + + // Insert the backdrop before the pane in the DOM order, + // in order to handle stacked overlays properly. + this._pane.parentElement.insertBefore(this._backdropElement, this._pane); + + // Forward backdrop clicks such that the consumer of the overlay can perform whatever + // action desired when such a click occurs (usually closing the overlay). + this._backdropElement.addEventListener('click', () => this._backdropClick.next(null)); + + // Add class to fade-in the backdrop after one frame. + requestAnimationFrame(() => { + if (this._backdropElement) { + this._backdropElement.classList.add('nz-overlay-backdrop-showing'); + } + }); + } + + /** + * Updates the stacking order of the element, moving it to the top if necessary. + * This is required in cases where one overlay was detached, while another one, + * that should be behind it, was destroyed. The next time both of them are opened, + * the stacking will be wrong, because the detached element's pane will still be + * in its original DOM position. + */ + private _updateStackingOrder() { + if (this._pane.nextSibling) { + this._pane.parentNode.appendChild(this._pane); + } + } + + /** Detaches the backdrop (if any) associated with the overlay. */ + detachBackdrop(): void { + const backdropToDetach = this._backdropElement; + + if (backdropToDetach) { + const finishDetach = () => { + // It may not be attached to anything in certain cases (e.g. unit tests). + if (backdropToDetach && backdropToDetach.parentNode) { + backdropToDetach.parentNode.removeChild(backdropToDetach); + } + + // It is possible that a new portal has been attached to this overlay since we started + // removing the backdrop. If that is the case, only clear the backdrop reference if it + // is still the same instance that we started to remove. + if (this._backdropElement === backdropToDetach) { + this._backdropElement = null; + } + }; + + backdropToDetach.classList.remove('nz-overlay-backdrop-showing'); + + if (this._state.backdropClass) { + backdropToDetach.classList.remove(this._state.backdropClass); + } + + backdropToDetach.addEventListener('transitionend', finishDetach); + + // If the backdrop doesn't have a transition, the `transitionend` event won't fire. + // In this case we make it unclickable and we try to remove it after a delay. + backdropToDetach.style.pointerEvents = 'none'; + + // Run this outside the Angular zone because there's nothing that Angular cares about. + // If it were to run inside the Angular zone, every test that used Overlay would have to be + // either async or fakeAsync. + this._ngZone.runOutsideAngular(() => { + setTimeout(finishDetach, 500); + }); + } + } +} + +function formatCssUnit(value: number | string) { + return typeof value === 'string' ? value as string : `${value}px`; +} diff --git a/src/components/core/overlay/overlay-state.ts b/src/components/core/overlay/overlay-state.ts new file mode 100755 index 00000000000..bec124ed51a --- /dev/null +++ b/src/components/core/overlay/overlay-state.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {PositionStrategy} from './position/position-strategy'; +import {Direction} from '@angular/cdk'; +import {ScrollStrategy} from './scroll/scroll-strategy'; + + +/** + * OverlayState is a bag of values for either the initial configuration or current state of an + * overlay. + */ +export class OverlayState { + /** Strategy with which to position the overlay. */ + positionStrategy: PositionStrategy; + + /** Strategy to be used when handling scroll events while the overlay is open. */ + scrollStrategy: ScrollStrategy; + + /** Custom class to add to the overlay pane. */ + panelClass = ''; + + /** Whether the overlay has a backdrop. */ + hasBackdrop = false; + + /** Custom class to add to the backdrop */ + backdropClass = 'cdk-overlay-dark-backdrop'; + + /** The width of the overlay panel. If a number is provided, pixel units are assumed. */ + width?: number | string; + + /** The height of the overlay panel. If a number is provided, pixel units are assumed. */ + height?: number | string; + + /** The min-width of the overlay panel. If a number is provided, pixel units are assumed. */ + minWidth?: number | string; + + /** The min-height of the overlay panel. If a number is provided, pixel units are assumed. */ + minHeight?: number | string; + + /** The direction of the text in the overlay panel. */ + direction?: Direction = 'ltr'; + + // TODO(jelbourn): configuration still to add + // - focus trap + // - disable pointer events + // - z-index +} diff --git a/src/components/core/overlay/overlay.ts b/src/components/core/overlay/overlay.ts new file mode 100755 index 00000000000..40d277754f1 --- /dev/null +++ b/src/components/core/overlay/overlay.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ComponentFactoryResolver, + Injectable, + ApplicationRef, + Injector, + NgZone, +} from '@angular/core'; +import { OverlayState } from './overlay-state'; +import { DomPortalHost } from '@angular/cdk'; +import { OverlayRef } from './overlay-ref'; +import { OverlayPositionBuilder } from './position/overlay-position-builder'; +import { OverlayContainer } from './overlay-container'; +import { ScrollStrategyOptions } from './scroll/index'; + + +/** Next overlay unique ID. */ +let nextUniqueId = 0; + +/** The default state for newly created overlays. */ +const defaultState = new OverlayState(); + + +/** + * Service to create Overlays. Overlays are dynamically added pieces of floating UI, meant to be + * used as a low-level building building block for other components. Dialogs, tooltips, menus, + * selects, etc. can all be built using overlays. The service should primarily be used by authors + * of re-usable components rather than developers building end-user applications. + * + * An overlay *is* a PortalHost, so any kind of Portal can be loaded into one. + */ +@Injectable() +export class Overlay { + constructor(public scrollStrategies: ScrollStrategyOptions, + private _overlayContainer: OverlayContainer, + private _componentFactoryResolver: ComponentFactoryResolver, + private _positionBuilder: OverlayPositionBuilder, + private _appRef: ApplicationRef, + private _injector: Injector, + private _ngZone: NgZone) { + } + + /** + * Creates an overlay. + * @param state State to apply to the overlay. + * @returns Reference to the created overlay. + */ + create(state: OverlayState = defaultState, paneClassName?: string): OverlayRef { + return this._createOverlayRef(this._createPaneElement(paneClassName), state); + } + + /** + * Returns a position builder that can be used, via fluent API, + * to construct and configure a position strategy. + */ + position(): OverlayPositionBuilder { + return this._positionBuilder; + } + + /** + * Creates the DOM element for an overlay and appends it to the overlay container. + * @returns Newly-created pane element + */ + private _createPaneElement(className?: string): HTMLElement { + const pane = document.createElement('div'); + + pane.id = `nz-overlay-${nextUniqueId++}`; + pane.classList.add('nz-overlay-pane'); + if (className) { + const classList = className.split(' '); + classList.forEach(c => { + pane.classList.add(c); + }) + } + this._overlayContainer.getContainerElement().appendChild(pane); + + return pane; + } + + /** + * Create a DomPortalHost into which the overlay content can be loaded. + * @param pane The DOM element to turn into a portal host. + * @returns A portal host for the given DOM element. + */ + private _createPortalHost(pane: HTMLElement): DomPortalHost { + return new DomPortalHost(pane, this._componentFactoryResolver, this._appRef, this._injector); + } + + /** + * Creates an OverlayRef for an overlay in the given DOM element. + * @param pane DOM element for the overlay + * @param state + */ + private _createOverlayRef(pane: HTMLElement, state: OverlayState): OverlayRef { + const scrollStrategy = state.scrollStrategy || this.scrollStrategies.noop(); + const portalHost = this._createPortalHost(pane); + return new OverlayRef(portalHost, pane, state, scrollStrategy, this._ngZone); + } +} diff --git a/src/components/core/overlay/position/connected-position-strategy.ts b/src/components/core/overlay/position/connected-position-strategy.ts new file mode 100755 index 00000000000..a9619424311 --- /dev/null +++ b/src/components/core/overlay/position/connected-position-strategy.ts @@ -0,0 +1,435 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {PositionStrategy} from './position-strategy'; +import {ElementRef} from '@angular/core'; +import {ViewportRuler} from './viewport-ruler'; +import { + ConnectionPositionPair, + OriginConnectionPosition, + OverlayConnectionPosition, + ConnectedOverlayPositionChange, ScrollableViewProperties +} from './connected-position'; +import {Subject} from 'rxjs/Subject'; +import {Observable} from 'rxjs/Observable'; +import {Scrollable} from '../scroll/scrollable'; + +/** + * Container to hold the bounding positions of a particular element with respect to the viewport, + * where top and bottom are the y-axis coordinates of the bounding rectangle and left and right are + * the x-axis coordinates. + */ +interface ElementBoundingPositions { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * A strategy for positioning overlays. Using this strategy, an overlay is given an + * implicit position relative some origin element. The relative position is defined in terms of + * a point on the origin element that is connected to a point on the overlay element. For example, + * a basic dropdown is connecting the bottom-left corner of the origin to the top-left corner + * of the overlay. + */ +export class ConnectedPositionStrategy implements PositionStrategy { + private _dir = 'ltr'; + + /** The offset in pixels for the overlay connection point on the x-axis */ + private _offsetX = 0; + + /** The offset in pixels for the overlay connection point on the y-axis */ + private _offsetY = 0; + + /** The Scrollable containers used to check scrollable view properties on position change. */ + private scrollables: Scrollable[] = []; + + /** Whether the we're dealing with an RTL context */ + get _isRtl() { + return this._dir === 'rtl'; + } + + /** Ordered list of preferred positions, from most to least desirable. */ + _preferredPositions: ConnectionPositionPair[] = []; + + /** The origin element against which the overlay will be positioned. */ + private _origin: HTMLElement; + + /** The overlay pane element. */ + private _pane: HTMLElement; + + /** The last position to have been calculated as the best fit position. */ + private _lastConnectedPosition: ConnectionPositionPair; + + _onPositionChange: + Subject = new Subject(); + + /** Emits an event when the connection point changes. */ + get onPositionChange(): Observable { + return this._onPositionChange.asObservable(); + } + + constructor( + private _connectedTo: ElementRef, + private _originPos: OriginConnectionPosition, + private _overlayPos: OverlayConnectionPosition, + private _viewportRuler: ViewportRuler) { + this._origin = this._connectedTo.nativeElement; + this.withFallbackPosition(_originPos, _overlayPos); + } + + /** Ordered list of preferred positions, from most to least desirable. */ + get positions() { + return this._preferredPositions; + } + + /** + * To be used to for any cleanup after the element gets destroyed. + */ + dispose() { } + + /** + * Updates the position of the overlay element, using whichever preferred position relative + * to the origin fits on-screen. + * @docs-private + * + * @param element Element to which to apply the CSS styles. + * @returns Resolves when the styles have been applied. + */ + apply(element: HTMLElement): void { + // Cache the overlay pane element in case re-calculating position is necessary + this._pane = element; + + // We need the bounding rects for the origin and the overlay to determine how to position + // the overlay relative to the origin. + const originRect = this._origin.getBoundingClientRect(); + const overlayRect = element.getBoundingClientRect(); + + // We use the viewport rect to determine whether a position would go off-screen. + const viewportRect = this._viewportRuler.getViewportRect(); + + // Fallback point if none of the fallbacks fit into the viewport. + let fallbackPoint: OverlayPoint | undefined; + let fallbackPosition: ConnectionPositionPair | undefined; + + // We want to place the overlay in the first of the preferred positions such that the + // overlay fits on-screen. + for (const pos of this._preferredPositions) { + // Get the (x, y) point of connection on the origin, and then use that to get the + // (top, left) coordinate for the overlay at `pos`. + const originPoint = this._getOriginConnectionPoint(originRect, pos); + const overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportRect, pos); + + // If the overlay in the calculated position fits on-screen, put it there and we're done. + if (overlayPoint.fitsInViewport) { + this._setElementPosition(element, overlayRect, overlayPoint, pos); + + // Save the last connected position in case the position needs to be re-calculated. + this._lastConnectedPosition = pos; + + // Notify that the position has been changed along with its change properties. + const scrollableViewProperties = this.getScrollableViewProperties(element); + const positionChange = new ConnectedOverlayPositionChange(pos, scrollableViewProperties); + this._onPositionChange.next(positionChange); + + return; + } else if (!fallbackPoint || fallbackPoint.visibleArea < overlayPoint.visibleArea) { + fallbackPoint = overlayPoint; + fallbackPosition = pos; + } + } + + // If none of the preferred positions were in the viewport, take the one + // with the largest visible area. + this._setElementPosition(element, overlayRect, fallbackPoint, fallbackPosition); + } + + /** + * This re-aligns the overlay element with the trigger in its last calculated position, + * even if a position higher in the "preferred positions" list would now fit. This + * allows one to re-align the panel without changing the orientation of the panel. + */ + recalculateLastPosition(): void { + const originRect = this._origin.getBoundingClientRect(); + const overlayRect = this._pane.getBoundingClientRect(); + const viewportRect = this._viewportRuler.getViewportRect(); + const lastPosition = this._lastConnectedPosition || this._preferredPositions[0]; + + const originPoint = this._getOriginConnectionPoint(originRect, lastPosition); + const overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportRect, lastPosition); + this._setElementPosition(this._pane, overlayRect, overlayPoint, lastPosition); + } + + /** + * Sets the list of Scrollable containers that host the origin element so that + * on reposition we can evaluate if it or the overlay has been clipped or outside view. Every + * Scrollable must be an ancestor element of the strategy's origin element. + */ + withScrollableContainers(scrollables: Scrollable[]) { + this.scrollables = scrollables; + } + + /** + * Adds a new preferred fallback position. + * @param originPos + * @param overlayPos + */ + withFallbackPosition( + originPos: OriginConnectionPosition, + overlayPos: OverlayConnectionPosition): this { + this._preferredPositions.push(new ConnectionPositionPair(originPos, overlayPos)); + return this; + } + + /** + * Sets the layout direction so the overlay's position can be adjusted to match. + * @param dir New layout direction. + */ + withDirection(dir: 'ltr' | 'rtl'): this { + this._dir = dir; + return this; + } + + /** + * Sets an offset for the overlay's connection point on the x-axis + * @param offset New offset in the X axis. + */ + withOffsetX(offset: number): this { + this._offsetX = offset; + return this; + } + + /** + * Sets an offset for the overlay's connection point on the y-axis + * @param offset New offset in the Y axis. + */ + withOffsetY(offset: number): this { + this._offsetY = offset; + return this; + } + + /** + * Gets the horizontal (x) "start" dimension based on whether the overlay is in an RTL context. + * @param rect + */ + private _getStartX(rect: ClientRect): number { + return this._isRtl ? rect.right : rect.left; + } + + /** + * Gets the horizontal (x) "end" dimension based on whether the overlay is in an RTL context. + * @param rect + */ + private _getEndX(rect: ClientRect): number { + return this._isRtl ? rect.left : rect.right; + } + + + /** + * Gets the (x, y) coordinate of a connection point on the origin based on a relative position. + * @param originRect + * @param pos + */ + private _getOriginConnectionPoint(originRect: ClientRect, pos: ConnectionPositionPair): Point { + const originStartX = this._getStartX(originRect); + const originEndX = this._getEndX(originRect); + + let x: number; + if (pos.originX === 'center') { + x = originStartX + (originRect.width / 2); + } else { + x = pos.originX === 'start' ? originStartX : originEndX; + } + + let y: number; + if (pos.originY === 'center') { + y = originRect.top + (originRect.height / 2); + } else { + y = pos.originY === 'top' ? originRect.top : originRect.bottom; + } + + return {x, y}; + } + + + /** + * Gets the (x, y) coordinate of the top-left corner of the overlay given a given position and + * origin point to which the overlay should be connected, as well as how much of the element + * would be inside the viewport at that position. + */ + private _getOverlayPoint( + originPoint: Point, + overlayRect: ClientRect, + viewportRect: ClientRect, + pos: ConnectionPositionPair): OverlayPoint { + // Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position + // relative to the origin point. + let overlayStartX: number; + if (pos.overlayX === 'center') { + overlayStartX = -overlayRect.width / 2; + } else if (pos.overlayX === 'start') { + overlayStartX = this._isRtl ? -overlayRect.width : 0; + } else { + overlayStartX = this._isRtl ? 0 : -overlayRect.width; + } + + let overlayStartY: number; + if (pos.overlayY === 'center') { + overlayStartY = -overlayRect.height / 2; + } else { + overlayStartY = pos.overlayY === 'top' ? 0 : -overlayRect.height; + } + + // The (x, y) coordinates of the overlay. + const x = originPoint.x + overlayStartX + this._offsetX; + const y = originPoint.y + overlayStartY + this._offsetY; + + // How much the overlay would overflow at this position, on each side. + const leftOverflow = 0 - x; + const rightOverflow = (x + overlayRect.width) - viewportRect.width; + const topOverflow = 0 - y; + const bottomOverflow = (y + overlayRect.height) - viewportRect.height; + + // Visible parts of the element on each axis. + const visibleWidth = this._subtractOverflows(overlayRect.width, leftOverflow, rightOverflow); + const visibleHeight = this._subtractOverflows(overlayRect.height, topOverflow, bottomOverflow); + + // The area of the element that's within the viewport. + const visibleArea = visibleWidth * visibleHeight; + const fitsInViewport = (overlayRect.width * overlayRect.height) === visibleArea; + + return {x, y, fitsInViewport, visibleArea}; + } + + /** + * Gets the view properties of the trigger and overlay, including whether they are clipped + * or completely outside the view of any of the strategy's scrollables. + */ + private getScrollableViewProperties(overlay: HTMLElement): ScrollableViewProperties { + const originBounds = this._getElementBounds(this._origin); + const overlayBounds = this._getElementBounds(overlay); + const scrollContainerBounds = this.scrollables.map((scrollable: Scrollable) => { + return this._getElementBounds(scrollable.getElementRef().nativeElement); + }); + + return { + isOriginClipped: this.isElementClipped(originBounds, scrollContainerBounds), + isOriginOutsideView: this.isElementOutsideView(originBounds, scrollContainerBounds), + isOverlayClipped: this.isElementClipped(overlayBounds, scrollContainerBounds), + isOverlayOutsideView: this.isElementOutsideView(overlayBounds, scrollContainerBounds), + }; + } + + /** Whether the element is completely out of the view of any of the containers. */ + private isElementOutsideView( + elementBounds: ElementBoundingPositions, + containersBounds: ElementBoundingPositions[]): boolean { + return containersBounds.some((containerBounds: ElementBoundingPositions) => { + const outsideAbove = elementBounds.bottom < containerBounds.top; + const outsideBelow = elementBounds.top > containerBounds.bottom; + const outsideLeft = elementBounds.right < containerBounds.left; + const outsideRight = elementBounds.left > containerBounds.right; + + return outsideAbove || outsideBelow || outsideLeft || outsideRight; + }); + } + + /** Whether the element is clipped by any of the containers. */ + private isElementClipped( + elementBounds: ElementBoundingPositions, + containersBounds: ElementBoundingPositions[]): boolean { + return containersBounds.some((containerBounds: ElementBoundingPositions) => { + const clippedAbove = elementBounds.top < containerBounds.top; + const clippedBelow = elementBounds.bottom > containerBounds.bottom; + const clippedLeft = elementBounds.left < containerBounds.left; + const clippedRight = elementBounds.right > containerBounds.right; + + return clippedAbove || clippedBelow || clippedLeft || clippedRight; + }); + } + + /** Physically positions the overlay element to the given coordinate. */ + private _setElementPosition( + element: HTMLElement, + overlayRect: ClientRect, + overlayPoint: Point, + pos: ConnectionPositionPair) { + + // We want to set either `top` or `bottom` based on whether the overlay wants to appear above + // or below the origin and the direction in which the element will expand. + const verticalStyleProperty = pos.overlayY === 'bottom' ? 'bottom' : 'top'; + + // When using `bottom`, we adjust the y position such that it is the distance + // from the bottom of the viewport rather than the top. + const y = verticalStyleProperty === 'top' ? + overlayPoint.y : + document.documentElement.clientHeight - (overlayPoint.y + overlayRect.height); + + // We want to set either `left` or `right` based on whether the overlay wants to appear "before" + // or "after" the origin, which determines the direction in which the element will expand. + // For the horizontal axis, the meaning of "before" and "after" change based on whether the + // page is in RTL or LTR. + let horizontalStyleProperty: string; + if (this._dir === 'rtl') { + horizontalStyleProperty = pos.overlayX === 'end' ? 'left' : 'right'; + } else { + horizontalStyleProperty = pos.overlayX === 'end' ? 'right' : 'left'; + } + + // When we're setting `right`, we adjust the x position such that it is the distance + // from the right edge of the viewport rather than the left edge. + const x = horizontalStyleProperty === 'left' ? + overlayPoint.x : + document.documentElement.clientWidth - (overlayPoint.x + overlayRect.width); + + + // Reset any existing styles. This is necessary in case the preferred position has + // changed since the last `apply`. + ['top', 'bottom', 'left', 'right'].forEach(p => element.style[p] = null); + + element.style[verticalStyleProperty] = `${y}px`; + element.style[horizontalStyleProperty] = `${x}px`; + } + + /** Returns the bounding positions of the provided element with respect to the viewport. */ + private _getElementBounds(element: HTMLElement): ElementBoundingPositions { + const boundingClientRect = element.getBoundingClientRect(); + return { + top: boundingClientRect.top, + right: boundingClientRect.left + boundingClientRect.width, + bottom: boundingClientRect.top + boundingClientRect.height, + left: boundingClientRect.left + }; + } + + /** + * Subtracts the amount that an element is overflowing on an axis from it's length. + */ + private _subtractOverflows(length: number, ...overflows: number[]): number { + return overflows.reduce((currentValue: number, currentOverflow: number) => { + return currentValue - Math.max(currentOverflow, 0); + }, length); + } +} + +/** A simple (x, y) coordinate. */ +interface Point { + x: number; + y: number; +} + +/** + * Expands the simple (x, y) coordinate by adding info about whether the + * element would fit inside the viewport at that position, as well as + * how much of the element would be visible. + */ +interface OverlayPoint extends Point { + visibleArea: number; + fitsInViewport: boolean; +} diff --git a/src/components/core/overlay/position/connected-position.ts b/src/components/core/overlay/position/connected-position.ts new file mode 100755 index 00000000000..02021649d23 --- /dev/null +++ b/src/components/core/overlay/position/connected-position.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Horizontal dimension of a connection point on the perimeter of the origin or overlay element. */ +import {Optional} from '@angular/core'; +export type HorizontalConnectionPos = 'start' | 'center' | 'end'; + +/** Vertical dimension of a connection point on the perimeter of the origin or overlay element. */ +export type VerticalConnectionPos = 'top' | 'center' | 'bottom'; + + +/** A connection point on the origin element. */ +export interface OriginConnectionPosition { + originX: HorizontalConnectionPos; + originY: VerticalConnectionPos; +} + +/** A connection point on the overlay element. */ +export interface OverlayConnectionPosition { + overlayX: HorizontalConnectionPos; + overlayY: VerticalConnectionPos; +} + +/** The points of the origin element and the overlay element to connect. */ +export class ConnectionPositionPair { + originX: HorizontalConnectionPos; + originY: VerticalConnectionPos; + overlayX: HorizontalConnectionPos; + overlayY: VerticalConnectionPos; + + constructor(origin: OriginConnectionPosition, overlay: OverlayConnectionPosition) { + this.originX = origin.originX; + this.originY = origin.originY; + this.overlayX = overlay.overlayX; + this.overlayY = overlay.overlayY; + } +} + +/** + * Set of properties regarding the position of the origin and overlay relative to the viewport + * with respect to the containing Scrollable elements. + * + * The overlay and origin are clipped if any part of their bounding client rectangle exceeds the + * bounds of any one of the strategy's Scrollable's bounding client rectangle. + * + * The overlay and origin are outside view if there is no overlap between their bounding client + * rectangle and any one of the strategy's Scrollable's bounding client rectangle. + * + * ----------- ----------- + * | outside | | clipped | + * | view | -------------------------- + * | | | | | | + * ---------- | ----------- | + * -------------------------- | | + * | | | Scrollable | + * | | | | + * | | -------------------------- + * | Scrollable | + * | | + * -------------------------- + */ +export class ScrollableViewProperties { + isOriginClipped: boolean; + isOriginOutsideView: boolean; + isOverlayClipped: boolean; + isOverlayOutsideView: boolean; +} + +/** The change event emitted by the strategy when a fallback position is used. */ +export class ConnectedOverlayPositionChange { + constructor(public connectionPair: ConnectionPositionPair, + @Optional() public scrollableViewProperties: ScrollableViewProperties) {} +} diff --git a/src/components/core/overlay/position/fake-viewport-ruler.ts b/src/components/core/overlay/position/fake-viewport-ruler.ts new file mode 100755 index 00000000000..b2f894ab3f0 --- /dev/null +++ b/src/components/core/overlay/position/fake-viewport-ruler.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** @docs-private */ +export class FakeViewportRuler { + getViewportRect() { + return { + left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014 + }; + } + + getViewportScrollPosition() { + return {top: 0, left: 0}; + } +} diff --git a/src/components/core/overlay/position/free-position-strategy.ts b/src/components/core/overlay/position/free-position-strategy.ts new file mode 100644 index 00000000000..182e3797bcf --- /dev/null +++ b/src/components/core/overlay/position/free-position-strategy.ts @@ -0,0 +1,80 @@ + import { PositionStrategy } from './position-strategy'; + + /** + * Free position strategy for overlay without origin + * @author lingyi.zcs + */ +export class FreePositionStrategy implements PositionStrategy { + private _wrapper: HTMLElement; + // private _cssPosition: string = ''; + // private _top: string = ''; + // private _left: string = ''; + // private _width: string = ''; + // private _height: string = ''; + + // cssPosition(value: string) { + // this._cssPosition = value; + // return this; + // } + + // top(value: number | string): this { + // this._top = this._toCssValue(value); + // return this; + // } + + // left(value: number | string): this { + // this._left = this._toCssValue(value); + // return this; + // } + + // width(value: number | string): this { + // this._width = this._toCssValue(value); + // return this; + // } + + // height(value: number | string): this { + // this._height = this._toCssValue(value); + // return this; + // } + + /** + * Apply the position to the element. (NOTE: normally will triggered by scrolling) + * @docs-private + * + * @param element Element to which to apply the CSS. + * @returns Resolved when the styles have been applied. + */ + apply(element: HTMLElement): void { + if (!this._wrapper) { + this._wrapper = document.createElement('div'); + this._wrapper.classList.add('cdk-free-overlay-wrapper'); + element.parentNode.insertBefore(this._wrapper, element); + this._wrapper.appendChild(element); + + // // Initialized style once + // const style = element.style; + // style.position = this._cssPosition; + // style.top = this._top; + // style.left = this._left; + // style.width = this._width; + // style.height = this._height; + } + + // TODO: do somethings while triggered (eg. by scrolling) + } + + /** + * Removes the wrapper element from the DOM. + */ + dispose(): void { + if (this._wrapper && this._wrapper.parentNode) { + this._wrapper.parentNode.removeChild(this._wrapper); + this._wrapper = null; + } + } + + // private _toCssValue(value: number | string) { + // return typeof value === 'number' ? value + 'px' : value; + // } + +} diff --git a/src/components/core/overlay/position/global-position-strategy.ts b/src/components/core/overlay/position/global-position-strategy.ts new file mode 100755 index 00000000000..c2d5266dd71 --- /dev/null +++ b/src/components/core/overlay/position/global-position-strategy.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {PositionStrategy} from './position-strategy'; + + +/** + * A strategy for positioning overlays. Using this strategy, an overlay is given an + * explicit position relative to the browser's viewport. We use flexbox, instead of + * transforms, in order to avoid issues with subpixel rendering which can cause the + * element to become blurry. + */ +export class GlobalPositionStrategy implements PositionStrategy { + private _cssPosition = 'static'; + private _topOffset = ''; + private _bottomOffset = ''; + private _leftOffset = ''; + private _rightOffset = ''; + private _alignItems = ''; + private _justifyContent = ''; + private _width = ''; + private _height = ''; + + /* A lazily-created wrapper for the overlay element that is used as a flex container. */ + private _wrapper: HTMLElement | null = null; + + /** + * Sets the top position of the overlay. Clears any previously set vertical position. + * @param value New top offset. + */ + top(value = ''): this { + this._bottomOffset = ''; + this._topOffset = value; + this._alignItems = 'flex-start'; + return this; + } + + /** + * Sets the left position of the overlay. Clears any previously set horizontal position. + * @param value New left offset. + */ + left(value = ''): this { + this._rightOffset = ''; + this._leftOffset = value; + this._justifyContent = 'flex-start'; + return this; + } + + /** + * Sets the bottom position of the overlay. Clears any previously set vertical position. + * @param value New bottom offset. + */ + bottom(value = ''): this { + this._topOffset = ''; + this._bottomOffset = value; + this._alignItems = 'flex-end'; + return this; + } + + /** + * Sets the right position of the overlay. Clears any previously set horizontal position. + * @param value New right offset. + */ + right(value = ''): this { + this._leftOffset = ''; + this._rightOffset = value; + this._justifyContent = 'flex-end'; + return this; + } + + /** + * Sets the overlay width and clears any previously set width. + * @param value New width for the overlay + */ + width(value = ''): this { + this._width = value; + + // When the width is 100%, we should reset the `left` and the offset, + // in order to ensure that the element is flush against the viewport edge. + if (value === '100%') { + this.left('0px'); + } + + return this; + } + + /** + * Sets the overlay height and clears any previously set height. + * @param value New height for the overlay + */ + height(value = ''): this { + this._height = value; + + // When the height is 100%, we should reset the `top` and the offset, + // in order to ensure that the element is flush against the viewport edge. + if (value === '100%') { + this.top('0px'); + } + + return this; + } + + /** + * Centers the overlay horizontally with an optional offset. + * Clears any previously set horizontal position. + * + * @param offset Overlay offset from the horizontal center. + */ + centerHorizontally(offset = ''): this { + this.left(offset); + this._justifyContent = 'center'; + return this; + } + + /** + * Centers the overlay vertically with an optional offset. + * Clears any previously set vertical position. + * + * @param offset Overlay offset from the vertical center. + */ + centerVertically(offset = ''): this { + this.top(offset); + this._alignItems = 'center'; + return this; + } + + /** + * Apply the position to the element. + * @docs-private + * + * @param element Element to which to apply the CSS. + * @returns Resolved when the styles have been applied. + */ + apply(element: HTMLElement): void { + if (!this._wrapper && element.parentNode) { + this._wrapper = document.createElement('div'); + this._wrapper.classList.add('cdk-global-overlay-wrapper'); + element.parentNode.insertBefore(this._wrapper, element); + this._wrapper.appendChild(element); + } + + const styles = element.style; + const parentStyles = (element.parentNode as HTMLElement).style; + + styles.position = this._cssPosition; + styles.marginTop = this._topOffset; + styles.marginLeft = this._leftOffset; + styles.marginBottom = this._bottomOffset; + styles.marginRight = this._rightOffset; + styles.width = this._width; + styles.height = this._height; + + parentStyles.justifyContent = this._justifyContent; + parentStyles.alignItems = this._alignItems; + } + + /** + * Removes the wrapper element from the DOM. + */ + dispose(): void { + if (this._wrapper && this._wrapper.parentNode) { + this._wrapper.parentNode.removeChild(this._wrapper); + this._wrapper = null; + } + } +} diff --git a/src/components/core/overlay/position/overlay-position-builder.ts b/src/components/core/overlay/position/overlay-position-builder.ts new file mode 100755 index 00000000000..10169e1453b --- /dev/null +++ b/src/components/core/overlay/position/overlay-position-builder.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ViewportRuler} from './viewport-ruler'; +import {ConnectedPositionStrategy} from './connected-position-strategy'; +import {ElementRef, Injectable} from '@angular/core'; +import {GlobalPositionStrategy} from './global-position-strategy'; +import {FreePositionStrategy} from './free-position-strategy'; +import {OverlayConnectionPosition, OriginConnectionPosition} from './connected-position'; + + + +/** Builder for overlay position strategy. */ +@Injectable() +export class OverlayPositionBuilder { + constructor(private _viewportRuler: ViewportRuler) { } + + /** + * Creates a free position strategy + */ + free(): FreePositionStrategy { + return new FreePositionStrategy(); + } + + /** + * Creates a global position strategy. + */ + global(): GlobalPositionStrategy { + return new GlobalPositionStrategy(); + } + + /** + * Creates a relative position strategy. + * @param elementRef + * @param originPos + * @param overlayPos + */ + connectedTo( + elementRef: ElementRef, + originPos: OriginConnectionPosition, + overlayPos: OverlayConnectionPosition): ConnectedPositionStrategy { + return new ConnectedPositionStrategy(elementRef, originPos, overlayPos, this._viewportRuler); + } +} diff --git a/src/components/core/overlay/position/position-strategy.ts b/src/components/core/overlay/position/position-strategy.ts new file mode 100755 index 00000000000..abc90be7300 --- /dev/null +++ b/src/components/core/overlay/position/position-strategy.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Strategy for setting the position on an overlay. */ +export interface PositionStrategy { + + /** Updates the position of the overlay element. */ + apply(element: Element): void; + + /** Cleans up any DOM modifications made by the position strategy, if necessary. */ + dispose(): void; +} diff --git a/src/components/core/overlay/position/viewport-ruler.ts b/src/components/core/overlay/position/viewport-ruler.ts new file mode 100755 index 00000000000..60ffb3cf9dd --- /dev/null +++ b/src/components/core/overlay/position/viewport-ruler.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injectable, Optional, SkipSelf} from '@angular/core'; +import {ScrollDispatcher} from '../scroll/scroll-dispatcher'; + + +/** + * Simple utility for getting the bounds of the browser viewport. + * @docs-private + */ +@Injectable() +export class ViewportRuler { + + /** Cached document client rectangle. */ + private _documentRect?: ClientRect; + + constructor(scrollDispatcher: ScrollDispatcher) { + // Subscribe to scroll and resize events and update the document rectangle on changes. + scrollDispatcher.scrolled(0, () => this._cacheViewportGeometry()); + } + + /** Gets a ClientRect for the viewport's bounds. */ + getViewportRect(documentRect = this._documentRect): ClientRect { + // Cache the document bounding rect so that we don't recompute it for multiple calls. + if (!documentRect) { + this._cacheViewportGeometry(); + documentRect = this._documentRect; + } + + // Use the document element's bounding rect rather than the window scroll properties + // (e.g. pageYOffset, scrollY) due to in issue in Chrome and IE where window scroll + // properties and client coordinates (boundingClientRect, clientX/Y, etc.) are in different + // conceptual viewports. Under most circumstances these viewports are equivalent, but they + // can disagree when the page is pinch-zoomed (on devices that support touch). + // See https://bugs.chromium.org/p/chromium/issues/detail?id=489206#c4 + // We use the documentElement instead of the body because, by default (without a css reset) + // browsers typically give the document body an 8px margin, which is not included in + // getBoundingClientRect(). + const scrollPosition = this.getViewportScrollPosition(documentRect); + const height = window.innerHeight; + const width = window.innerWidth; + + return { + top: scrollPosition.top, + left: scrollPosition.left, + bottom: scrollPosition.top + height, + right: scrollPosition.left + width, + height, + width, + }; + } + + + /** + * Gets the (top, left) scroll position of the viewport. + * @param documentRect + */ + getViewportScrollPosition(documentRect = this._documentRect) { + // Cache the document bounding rect so that we don't recompute it for multiple calls. + if (!documentRect) { + this._cacheViewportGeometry(); + documentRect = this._documentRect; + } + + // The top-left-corner of the viewport is determined by the scroll position of the document + // body, normally just (scrollLeft, scrollTop). However, Chrome and Firefox disagree about + // whether `document.body` or `document.documentElement` is the scrolled element, so reading + // `scrollTop` and `scrollLeft` is inconsistent. However, using the bounding rect of + // `document.documentElement` works consistently, where the `top` and `left` values will + // equal negative the scroll position. + const top = -documentRect.top || document.body.scrollTop || window.scrollY || + document.documentElement.scrollTop || 0; + + const left = -documentRect.left || document.body.scrollLeft || window.scrollX || + document.documentElement.scrollLeft || 0; + + return {top, left}; + } + + /** Caches the latest client rectangle of the document element. */ + _cacheViewportGeometry() { + this._documentRect = document.documentElement.getBoundingClientRect(); + } + +} + +export function VIEWPORT_RULER_PROVIDER_FACTORY(parentRuler: ViewportRuler, + scrollDispatcher: ScrollDispatcher) { + return parentRuler || new ViewportRuler(scrollDispatcher); +} + +export const VIEWPORT_RULER_PROVIDER = { + // If there is already a ViewportRuler available, use that. Otherwise, provide a new one. + provide: ViewportRuler, + deps: [[new Optional(), new SkipSelf(), ViewportRuler], ScrollDispatcher], + useFactory: VIEWPORT_RULER_PROVIDER_FACTORY +}; diff --git a/src/components/core/overlay/scroll/block-scroll-strategy.ts b/src/components/core/overlay/scroll/block-scroll-strategy.ts new file mode 100755 index 00000000000..ef60e7d6084 --- /dev/null +++ b/src/components/core/overlay/scroll/block-scroll-strategy.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ScrollStrategy} from './scroll-strategy'; +import {ViewportRuler} from '../position/viewport-ruler'; + +/** + * Strategy that will prevent the user from scrolling while the overlay is visible. + */ +export class BlockScrollStrategy implements ScrollStrategy { + private _previousHTMLStyles = { top: '', left: '' }; + private _previousScrollPosition: { top: number, left: number }; + private _isEnabled = false; + + constructor(private _viewportRuler: ViewportRuler) { } + + attach() { } + + enable() { + if (this._canBeEnabled()) { + const root = document.documentElement; + + this._previousScrollPosition = this._viewportRuler.getViewportScrollPosition(); + + // Cache the previous inline styles in case the user had set them. + this._previousHTMLStyles.left = root.style.left || ''; + this._previousHTMLStyles.top = root.style.top || ''; + + // Note: we're using the `html` node, instead of the `body`, because the `body` may + // have the user agent margin, whereas the `html` is guaranteed not to have one. + root.style.left = `${-this._previousScrollPosition.left}px`; + root.style.top = `${-this._previousScrollPosition.top}px`; + root.classList.add('cdk-global-scrollblock'); + this._isEnabled = true; + } + } + + disable() { + if (this._isEnabled) { + this._isEnabled = false; + document.documentElement.style.left = this._previousHTMLStyles.left; + document.documentElement.style.top = this._previousHTMLStyles.top; + document.documentElement.classList.remove('cdk-global-scrollblock'); + window.scroll(this._previousScrollPosition.left, this._previousScrollPosition.top); + } + } + + private _canBeEnabled(): boolean { + // Since the scroll strategies can't be singletons, we have to use a global CSS class + // (`cdk-global-scrollblock`) to make sure that we don't try to disable global + // scrolling multiple times. + if (document.documentElement.classList.contains('cdk-global-scrollblock') || this._isEnabled) { + return false; + } + + const body = document.body; + const viewport = this._viewportRuler.getViewportRect(); + return body.scrollHeight > viewport.height || body.scrollWidth > viewport.width; + } +} diff --git a/src/components/core/overlay/scroll/close-scroll-strategy.ts b/src/components/core/overlay/scroll/close-scroll-strategy.ts new file mode 100755 index 00000000000..edcf2c76574 --- /dev/null +++ b/src/components/core/overlay/scroll/close-scroll-strategy.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ScrollStrategy, getMdScrollStrategyAlreadyAttachedError} from './scroll-strategy'; +import {OverlayRef} from '../overlay-ref'; +import {Subscription} from 'rxjs/Subscription'; +import {ScrollDispatcher} from './scroll-dispatcher'; + + +/** + * Strategy that will close the overlay as soon as the user starts scrolling. + */ +export class CloseScrollStrategy implements ScrollStrategy { + private _scrollSubscription: Subscription|null = null; + private _overlayRef: OverlayRef; + + constructor(private _scrollDispatcher: ScrollDispatcher) { } + + attach(overlayRef: OverlayRef) { + if (this._overlayRef) { + throw getMdScrollStrategyAlreadyAttachedError(); + } + + this._overlayRef = overlayRef; + } + + enable() { + if (!this._scrollSubscription) { + this._scrollSubscription = this._scrollDispatcher.scrolled(0, () => { + if (this._overlayRef.hasAttached()) { + this._overlayRef.detach(); + } + + this.disable(); + }); + } + } + + disable() { + if (this._scrollSubscription) { + this._scrollSubscription.unsubscribe(); + this._scrollSubscription = null; + } + } +} diff --git a/src/components/core/overlay/scroll/index.ts b/src/components/core/overlay/scroll/index.ts new file mode 100755 index 00000000000..686294b9ad2 --- /dev/null +++ b/src/components/core/overlay/scroll/index.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {SCROLL_DISPATCHER_PROVIDER} from './scroll-dispatcher'; +import {Scrollable} from './scrollable'; +import {PlatformModule} from '@angular/cdk'; +import {ScrollStrategyOptions} from './scroll-strategy-options'; + +export {Scrollable} from './scrollable'; +export {ScrollDispatcher} from './scroll-dispatcher'; + +// Export pre-defined scroll strategies and interface to build custom ones. +export {ScrollStrategy} from './scroll-strategy'; +export {ScrollStrategyOptions} from './scroll-strategy-options'; +export {RepositionScrollStrategy} from './reposition-scroll-strategy'; +export {CloseScrollStrategy} from './close-scroll-strategy'; +export {NoopScrollStrategy} from './noop-scroll-strategy'; +export {BlockScrollStrategy} from './block-scroll-strategy'; + +@NgModule({ + imports: [PlatformModule], + exports: [Scrollable], + declarations: [Scrollable], + providers: [SCROLL_DISPATCHER_PROVIDER, ScrollStrategyOptions], +}) +export class ScrollDispatchModule { } diff --git a/src/components/core/overlay/scroll/noop-scroll-strategy.ts b/src/components/core/overlay/scroll/noop-scroll-strategy.ts new file mode 100755 index 00000000000..64a4bfb1276 --- /dev/null +++ b/src/components/core/overlay/scroll/noop-scroll-strategy.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ScrollStrategy} from './scroll-strategy'; + +/** + * Scroll strategy that doesn't do anything. + */ +export class NoopScrollStrategy implements ScrollStrategy { + enable() { } + disable() { } + attach() { } +} diff --git a/src/components/core/overlay/scroll/reposition-scroll-strategy.ts b/src/components/core/overlay/scroll/reposition-scroll-strategy.ts new file mode 100755 index 00000000000..b35f4232b68 --- /dev/null +++ b/src/components/core/overlay/scroll/reposition-scroll-strategy.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Subscription} from 'rxjs/Subscription'; +import {ScrollStrategy, getMdScrollStrategyAlreadyAttachedError} from './scroll-strategy'; +import {OverlayRef} from '../overlay-ref'; +import {ScrollDispatcher} from './scroll-dispatcher'; + +/** + * Config options for the RepositionScrollStrategy. + */ +export interface RepositionScrollStrategyConfig { + scrollThrottle?: number; +} + +/** + * Strategy that will update the element position as the user is scrolling. + */ +export class RepositionScrollStrategy implements ScrollStrategy { + private _scrollSubscription: Subscription|null = null; + private _overlayRef: OverlayRef; + + constructor( + private _scrollDispatcher: ScrollDispatcher, + private _config?: RepositionScrollStrategyConfig) { } + + attach(overlayRef: OverlayRef) { + if (this._overlayRef) { + throw getMdScrollStrategyAlreadyAttachedError(); + } + + this._overlayRef = overlayRef; + } + + enable() { + if (!this._scrollSubscription) { + const throttle = this._config ? this._config.scrollThrottle : 0; + + this._scrollSubscription = this._scrollDispatcher.scrolled(throttle, () => { + this._overlayRef.updatePosition(); + }); + } + } + + disable() { + if (this._scrollSubscription) { + this._scrollSubscription.unsubscribe(); + this._scrollSubscription = null; + } + } +} diff --git a/src/components/core/overlay/scroll/scroll-dispatcher.ts b/src/components/core/overlay/scroll/scroll-dispatcher.ts new file mode 100755 index 00000000000..133913266e8 --- /dev/null +++ b/src/components/core/overlay/scroll/scroll-dispatcher.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ElementRef, Injectable, NgZone, Optional, SkipSelf} from '@angular/core'; +import {Platform} from '@angular/cdk'; +import {Scrollable} from './scrollable'; +import {Subject} from 'rxjs/Subject'; +import {Subscription} from 'rxjs/Subscription'; +import {fromEvent} from 'rxjs/observable/fromEvent'; +import {merge} from 'rxjs/observable/merge'; +import {auditTime} from '@angular/cdk'; + + +/** Time in ms to throttle the scrolling events by default. */ +export const DEFAULT_SCROLL_TIME = 20; + +/** + * Service contained all registered Scrollable references and emits an event when any one of the + * Scrollable references emit a scrolled event. + */ +@Injectable() +export class ScrollDispatcher { + + /** Subject for notifying that a registered scrollable reference element has been scrolled. */ + _scrolled: Subject = new Subject(); + + /** Keeps track of the global `scroll` and `resize` subscriptions. */ + _globalSubscription: Subscription | null = null; + + /** Keeps track of the amount of subscriptions to `scrolled`. Used for cleaning up afterwards. */ + private _scrolledCount = 0; + + /** + * Map of all the scrollable references that are registered with the service and their + * scroll event subscriptions. + */ + scrollableReferences: Map = new Map(); + constructor(private _ngZone: NgZone, private _platform: Platform) { } + + /** + * Registers a Scrollable with the service and listens for its scrolled events. When the + * scrollable is scrolled, the service emits the event in its scrolled observable. + * @param scrollable Scrollable instance to be registered. + */ + register(scrollable: Scrollable): void { + const scrollSubscription = scrollable.elementScrolled().subscribe(() => this._notify()); + + this.scrollableReferences.set(scrollable, scrollSubscription); + } + + /** + * Deregisters a Scrollable reference and unsubscribes from its scroll event observable. + * @param scrollable Scrollable instance to be deregistered. + */ + deregister(scrollable: Scrollable): void { + const scrollableReference = this.scrollableReferences.get(scrollable); + + if (scrollableReference) { + scrollableReference.unsubscribe(); + this.scrollableReferences.delete(scrollable); + } + } + + /** + * Subscribes to an observable that emits an event whenever any of the registered Scrollable + * references (or window, document, or body) fire a scrolled event. Can provide a time in ms + * to override the default "throttle" time. + */ + scrolled(auditTimeInMs: number = DEFAULT_SCROLL_TIME, callback: () => any): Subscription { + // Scroll events can only happen on the browser, so do nothing if we're not on the browser. + if (!this._platform.isBrowser) { + return Subscription.EMPTY; + } + + // In the case of a 0ms delay, use an observable without auditTime + // since it does add a perceptible delay in processing overhead. + const observable = auditTimeInMs > 0 ? + auditTime.call(this._scrolled.asObservable(), auditTimeInMs) : + this._scrolled.asObservable(); + + this._scrolledCount++; + + if (!this._globalSubscription) { + this._globalSubscription = this._ngZone.runOutsideAngular(() => { + return merge( + fromEvent(window.document, 'scroll'), + fromEvent(window, 'resize') + ).subscribe(() => this._notify()); + }); + } + + // Note that we need to do the subscribing from here, in order to be able to remove + // the global event listeners once there are no more subscriptions. + const subscription = observable.subscribe(callback); + + subscription.add(() => { + this._scrolledCount--; + + if (this._globalSubscription && !this.scrollableReferences.size && !this._scrolledCount) { + this._globalSubscription.unsubscribe(); + this._globalSubscription = null; + } + }); + + return subscription; + } + + /** Returns all registered Scrollables that contain the provided element. */ + getScrollContainers(elementRef: ElementRef): Scrollable[] { + const scrollingContainers: Scrollable[] = []; + + this.scrollableReferences.forEach((_subscription: Subscription, scrollable: Scrollable) => { + if (this.scrollableContainsElement(scrollable, elementRef)) { + scrollingContainers.push(scrollable); + } + }); + + return scrollingContainers; + } + + /** Returns true if the element is contained within the provided Scrollable. */ + scrollableContainsElement(scrollable: Scrollable, elementRef: ElementRef): boolean { + let element = elementRef.nativeElement; + const scrollableElement = scrollable.getElementRef().nativeElement; + + // Traverse through the element parents until we reach null, checking if any of the elements + // are the scrollable's element. + do { + if (element === scrollableElement) { return true; } + } while (element = element.parentElement); + + return false; + } + + /** Sends a notification that a scroll event has been fired. */ + _notify() { + this._scrolled.next(); + } +} + +export function SCROLL_DISPATCHER_PROVIDER_FACTORY( + parentDispatcher: ScrollDispatcher, ngZone: NgZone, platform: Platform) { + return parentDispatcher || new ScrollDispatcher(ngZone, platform); +} + +export const SCROLL_DISPATCHER_PROVIDER = { + // If there is already a ScrollDispatcher available, use that. Otherwise, provide a new one. + provide: ScrollDispatcher, + deps: [[new Optional(), new SkipSelf(), ScrollDispatcher], NgZone, Platform], + useFactory: SCROLL_DISPATCHER_PROVIDER_FACTORY +}; diff --git a/src/components/core/overlay/scroll/scroll-strategy-options.ts b/src/components/core/overlay/scroll/scroll-strategy-options.ts new file mode 100755 index 00000000000..be6a2dec0b8 --- /dev/null +++ b/src/components/core/overlay/scroll/scroll-strategy-options.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injectable} from '@angular/core'; +import {ScrollStrategy} from './scroll-strategy'; +import {CloseScrollStrategy} from './close-scroll-strategy'; +import {NoopScrollStrategy} from './noop-scroll-strategy'; +import {BlockScrollStrategy} from './block-scroll-strategy'; +import {ScrollDispatcher} from './scroll-dispatcher'; +import {ViewportRuler} from '../position/viewport-ruler'; +import { + RepositionScrollStrategy, + RepositionScrollStrategyConfig, +} from './reposition-scroll-strategy'; + + +/** + * Options for how an overlay will handle scrolling. + * + * Users can provide a custom value for `ScrollStrategyOptions` to replace the default + * behaviors. This class primarily acts as a factory for ScrollStrategy instances. + */ +@Injectable() +export class ScrollStrategyOptions { + constructor( + private _scrollDispatcher: ScrollDispatcher, + private _viewportRuler: ViewportRuler) { } + + /** Do nothing on scroll. */ + noop = () => new NoopScrollStrategy(); + + /** Close the overlay as soon as the user scrolls. */ + close = () => new CloseScrollStrategy(this._scrollDispatcher); + + /** Block scrolling. */ + block = () => new BlockScrollStrategy(this._viewportRuler); + + /** + * Update the overlay's position on scroll. + * @param config Configuration to be used inside the scroll strategy. + * Allows debouncing the reposition calls. + */ + reposition = (config?: RepositionScrollStrategyConfig) => + new RepositionScrollStrategy(this._scrollDispatcher, config) +} diff --git a/src/components/core/overlay/scroll/scroll-strategy.md b/src/components/core/overlay/scroll/scroll-strategy.md new file mode 100755 index 00000000000..b141bd5fbba --- /dev/null +++ b/src/components/core/overlay/scroll/scroll-strategy.md @@ -0,0 +1,39 @@ +# Scroll strategies + +## What is a scroll strategy? +A scroll strategy is a way of describing how an overlay should behave if the user scrolls +while the overlay is open. The strategy has a reference to the `OverlayRef`, allowing it to +recalculate the position, close the overlay, block scrolling, etc. + +## Usage +To associate an overlay with a scroll strategy, you have to pass in a function, that returns a +scroll strategy, to the `OverlayState`. By default, all overlays will use the `noop` strategy which +doesn't do anything. The other available strategies are `reposition`, `block` and `close`: + +```ts +let overlayState = new OverlayState(); + +overlayState.scrollStrategy = overlay.scrollStrategies.block(); +this._overlay.create(overlayState).attach(yourPortal); +``` + +## Creating a custom scroll strategy +To set up a custom scroll strategy, you have to create a class that implements the `ScrollStrategy` +interface. There are three stages of a scroll strategy's life cycle: + +1. When an overlay is created, it'll call the strategy's `attach` method with a reference to itself. +2. When an overlay is attached to the DOM, it'll call the `enable` method on its scroll strategy, +3. When an overlay is detached from the DOM or destroyed, it'll call the `disable` method on its +scroll strategy, allowing it to clean up after itself. + +Afterwards you can pass in the new scroll strategy to your overlay state: + +```ts +// Your custom scroll strategy. +export class CustomScrollStrategy implements ScrollStrategy { + // your implementation +} + +overlayState.scrollStrategy = new CustomScrollStrategy(); +this._overlay.create(overlayState).attach(yourPortal); +``` diff --git a/src/components/core/overlay/scroll/scroll-strategy.ts b/src/components/core/overlay/scroll/scroll-strategy.ts new file mode 100755 index 00000000000..72c9eee70fa --- /dev/null +++ b/src/components/core/overlay/scroll/scroll-strategy.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {OverlayRef} from '../overlay-ref'; + +/** + * Describes a strategy that will be used by an overlay + * to handle scroll events while it is open. + */ +export abstract class ScrollStrategy { + enable: () => void; + disable: () => void; + attach: (overlayRef: OverlayRef) => void; +} + +/** + * Returns an error to be thrown when attempting to attach an already-attached scroll strategy. + */ +export function getMdScrollStrategyAlreadyAttachedError(): Error { + return Error(`Scroll strategy has already been attached.`); +} diff --git a/src/components/core/overlay/scroll/scrollable.ts b/src/components/core/overlay/scroll/scrollable.ts new file mode 100755 index 00000000000..17e185e75a6 --- /dev/null +++ b/src/components/core/overlay/scroll/scrollable.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, OnInit, OnDestroy, NgZone, Renderer2} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; +import {ScrollDispatcher} from './scroll-dispatcher'; + + +/** + * Sends an event when the directive's element is scrolled. Registers itself with the + * ScrollDispatcher service to include itself as part of its collection of scrolling events that it + * can be listened to through the service. + */ +@Directive({ + selector: '[cdk-scrollable], [cdkScrollable]' +}) +export class Scrollable implements OnInit, OnDestroy { + private _elementScrolled: Subject = new Subject(); + private _scrollListener: Function | null; + + constructor(private _elementRef: ElementRef, + private _scroll: ScrollDispatcher, + private _ngZone: NgZone, + private _renderer: Renderer2) {} + + ngOnInit() { + this._scrollListener = this._ngZone.runOutsideAngular(() => { + return this._renderer.listen(this.getElementRef().nativeElement, 'scroll', (event: Event) => { + this._elementScrolled.next(event); + }); + }); + + this._scroll.register(this); + } + + ngOnDestroy() { + this._scroll.deregister(this); + + if (this._scrollListener) { + this._scrollListener(); + this._scrollListener = null; + } + } + + /** + * Returns observable that emits when a scroll event is fired on the host element. + */ + elementScrolled(): Observable { + return this._elementScrolled.asObservable(); + } + + getElementRef(): ElementRef { + return this._elementRef; + } +} diff --git a/src/components/datepicker/nz-datepicker.component.ts b/src/components/datepicker/nz-datepicker.component.ts new file mode 100644 index 00000000000..2959aa301d3 --- /dev/null +++ b/src/components/datepicker/nz-datepicker.component.ts @@ -0,0 +1,403 @@ +import { + Component, + ViewEncapsulation, + Input, + ElementRef, + forwardRef, + ChangeDetectorRef, + ViewChild, + HostBinding, OnInit +} from '@angular/core'; +import * as moment from 'moment'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { DropDownAnimation } from '../core/animation/dropdown-animations'; +import { NzTimePickerInnerComponent } from '../time-picker/nz-timepicker-inner.component'; +import { DEFAULT_DATEPICKER_POSITIONS } from '../core/overlay/overlay-position-map'; +import { ConnectionPositionPair } from '../core/overlay'; + +@Component({ + selector : 'nz-datepicker', + encapsulation: ViewEncapsulation.None, + animations : [ + DropDownAnimation + ], + template : ` + + + + + + + +
+ `, + providers : [ + { + provide : NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NzDatePickerComponent), + multi : true + } + ], + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) +export class NzDatePickerComponent implements ControlValueAccessor, OnInit { + _el: HTMLElement; + _open = false; + _mode = 'year'; + _dropDownPosition = 'bottom'; + _triggerWidth = 0; + _value = null; + _today = new Date(); + _selectedMonth = moment(this.nzValue).month(); + _selectedYear = moment(this.nzValue).year(); + _selectedDate = moment(this.nzValue).date(); + _showMonth = moment(new Date()).month(); + _showYear = moment(new Date()).year(); + _startDecade = Math.floor(this._showYear / 10) * 10; + _yearPanel: Array> = []; + _positions: ConnectionPositionPair[] = [ ...DEFAULT_DATEPICKER_POSITIONS ]; + // ngModel Access + onChange: any = Function.prototype; + onTouched: any = Function.prototype; + @Input() nzDisabledDate; + @Input() nzAllowClear = true; + @Input() nzShowTime: any = null; + @Input() nzPlaceHolder = '请选择日期'; + @Input() nzFormat = 'YYYY-MM-DD'; + @Input() nzSize = ''; + @Input() nzDisabled = false; + @ViewChild('trigger') trigger; + @ViewChild(NzTimePickerInnerComponent) timePickerInner: NzTimePickerInnerComponent; + @HostBinding('class.ant-calendar-picker') true; + + _setTriggerWidth(): void { + this._triggerWidth = this.trigger.nativeElement.getBoundingClientRect().width; + } + + onPositionChange(position) { + const _position = position.connectionPair.originY === 'bottom' ? 'top' : 'bottom'; + if (this._dropDownPosition !== _position) { + this._dropDownPosition = _position; + this._cdr.detectChanges(); + } + } + + + get nzValue(): Date { + return this._value || new Date(); + }; + + set nzValue(value: Date) { + if (this._value === value) { + return; + } + this._value = value; + this._selectedMonth = moment(this.nzValue).month(); + this._selectedYear = moment(this.nzValue).year(); + this._selectedDate = moment(this.nzValue).date(); + this._showYear = moment(this.nzValue).year(); + this._showMonth = moment(this.nzValue).month(); + this._startDecade = Math.floor(this._showYear / 10) * 10; + }; + + _changeTime($event) { + this._value = $event; + } + + _blurInput(box) { + if (Date.parse(box.value)) { + this.nzValue = new Date(box.value); + this.onChange(this._value); + } + } + + _preYear() { + this._showYear = this._showYear - 1; + } + + _nextYear() { + this._showYear = this._showYear + 1; + } + + _preMonth() { + if (this._showMonth - 1 < 0) { + this._showMonth = 11; + this._preYear(); + } else { + this._showMonth = this._showMonth - 1; + } + } + + _nextMonth() { + if (this._showMonth + 1 > 11) { + this._showMonth = 0; + this._nextYear(); + } else { + this._showMonth = this._showMonth + 1; + } + } + + _setShowYear(year, $event) { + $event.stopPropagation(); + this._showYear = year; + this._mode = 'year'; + } + + _preDecade() { + this._startDecade = this._startDecade - 10; + } + + _nextDecade() { + this._startDecade = this._startDecade + 10; + } + + _clearValue(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + this.nzValue = null; + this.onChange(this._value); + } + + _changeToToday() { + this.nzValue = new Date(); + this.onChange(this._value); + this._closeCalendar(); + } + + _clickDay(day) { + if (!this.nzShowTime) { + this._closeCalendar(); + this.nzValue = day.date.toDate(); + this.onChange(this._value); + } else { + this.nzValue = moment(this.nzValue).year(day.date.year()).month(day.date.month()).date(day.date.date()).toDate(); + this.onChange(this._value); + } + + } + + _clickMonth(month) { + this._showMonth = month.index; + this._mode = 'year'; + } + + _openCalendar() { + if (this.nzDisabled) { + return; + } + this._mode = 'year'; + this._open = true; + this._setTriggerWidth(); + } + + _closeCalendar() { + if (!this._open) { + return; + } + if (this.nzShowTime) { + this.onChange(this._value); + } + this._open = false; + } + + _changeMonthView() { + this._mode = 'month'; + } + + _changeDecadeView($event) { + $event.stopPropagation(); + this._mode = 'decade'; + } + + _changeTimeView($event) { + $event.stopPropagation(); + this._mode = 'time'; + setTimeout(_ => { + this.timePickerInner._initPosition(); + }); + } + + _changeYearView($event) { + $event.stopPropagation(); + this._mode = 'year'; + } + + get _showClearIcon() { + return this._value && !this.nzDisabled && this.nzAllowClear; + } + + _generateYearPanel() { + let _t = []; + for (let i = 0; i < 10; i++) { + if (i === 1 || i === 4 || i === 7 || i === 9) { + _t.push(i); + this._yearPanel.push(_t); + _t = []; + } else { + _t.push(i); + } + } + this._yearPanel[ 0 ].unshift('start'); + this._yearPanel[ 3 ].push('end'); + } + + constructor(private _elementRef: ElementRef, private _cdr: ChangeDetectorRef) { + this._el = this._elementRef.nativeElement; + } + + ngOnInit() { + this._generateYearPanel(); + } + + writeValue(value: any): void { + this.nzValue = value; + } + + registerOnChange(fn: (_: any) => {}): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => {}): void { + this.onTouched = fn; + } +} diff --git a/src/components/datepicker/nz-datepicker.module.ts b/src/components/datepicker/nz-datepicker.module.ts new file mode 100644 index 00000000000..71204e33dd6 --- /dev/null +++ b/src/components/datepicker/nz-datepicker.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { NzDatePickerComponent } from './nz-datepicker.component'; +import { CommonModule } from '@angular/common'; +import { NzInputModule } from '../input/nz-input.module'; +import { NzTimePickerModule } from '../time-picker/nz-timepicker.module'; +import { NzUtilModule } from '../util/nz-util.module'; +import { NzCalendarModule } from '../calendar/nz-calendar.module'; +import { FormsModule } from '@angular/forms'; +import { OverlayModule } from '../core/overlay'; + +@NgModule({ + imports : [ CommonModule, NzTimePickerModule, NzUtilModule, NzInputModule, NzCalendarModule, FormsModule, OverlayModule ], + declarations: [ NzDatePickerComponent ], + exports : [ NzDatePickerComponent ] +}) + +export class NzDatePickerModule { +} diff --git a/src/components/datepicker/style/Calendar.less b/src/components/datepicker/style/Calendar.less new file mode 100755 index 00000000000..6550e7073c0 --- /dev/null +++ b/src/components/datepicker/style/Calendar.less @@ -0,0 +1,334 @@ +.calendarPanelHeader(@calendar-prefix-cls) { + height: 34px; + line-height: 34px; + text-align: center; + user-select: none; + border-bottom: @border-width-base @border-style-base @border-color-split; + + a:hover { + color: @link-hover-color; + } + + .@{calendar-prefix-cls}-century-select, + .@{calendar-prefix-cls}-decade-select, + .@{calendar-prefix-cls}-year-select, + .@{calendar-prefix-cls}-month-select { + padding: 0 2px; + font-weight: bold; + display: inline-block; + color: @text-color; + line-height: 34px; + } + + .@{calendar-prefix-cls}-century-select-arrow, + .@{calendar-prefix-cls}-decade-select-arrow, + .@{calendar-prefix-cls}-year-select-arrow, + .@{calendar-prefix-cls}-month-select-arrow { + display: none; + } + + .@{calendar-prefix-cls}-prev-century-btn, + .@{calendar-prefix-cls}-next-century-btn, + .@{calendar-prefix-cls}-prev-decade-btn, + .@{calendar-prefix-cls}-next-decade-btn, + .@{calendar-prefix-cls}-prev-month-btn, + .@{calendar-prefix-cls}-next-month-btn, + .@{calendar-prefix-cls}-prev-year-btn, + .@{calendar-prefix-cls}-next-year-btn { + position: absolute; + top: 0; + color: @text-color-secondary; + font-family: Arial, "Hiragino Sans GB", "Microsoft Yahei", "Microsoft Sans Serif", sans-serif; + padding: 0 5px; + font-size: 16px; + display: inline-block; + line-height: 34px; + } + + .@{calendar-prefix-cls}-prev-century-btn, + .@{calendar-prefix-cls}-prev-decade-btn, + .@{calendar-prefix-cls}-prev-year-btn { + left: 7px; + + &:after { + content: '«'; + } + } + + .@{calendar-prefix-cls}-next-century-btn, + .@{calendar-prefix-cls}-next-decade-btn, + .@{calendar-prefix-cls}-next-year-btn { + right: 7px; + + &:after { + content: '»'; + } + } + + .@{calendar-prefix-cls}-prev-month-btn { + left: 29px; + + &:after { + content: '‹'; + } + } + + .@{calendar-prefix-cls}-next-month-btn { + right: 29px; + + &:after { + content: '›'; + } + } +} + +.@{calendar-prefix-cls} { + position: relative; + outline: none; + width: 231px; + border: @border-width-base @border-style-base #fff; + list-style: none; + font-size: @font-size-base; + text-align: left; + background-color: @component-background; + border-radius: @border-radius-base; + box-shadow: @box-shadow-base; + background-clip: padding-box; + line-height: @line-height-base; + + &-input-wrap { + height: 34px; + padding: 6px; + border-bottom: @border-width-base @border-style-base @border-color-split; + } + + &-input { + border: 0; + width: 100%; + cursor: auto; + outline: 0; + height: 22px; + color: @input-color; + background: @input-bg; + .placeholder; + } + + &-week-number { + width: 286px; + + &-cell { + text-align: center; + } + } + + &-header { + .calendarPanelHeader(@calendar-prefix-cls); + } + + &-body { + padding: 4px 8px; + } + + table { + border-collapse: collapse; + max-width: 100%; + background-color: transparent; + width: 100%; + } + + table, + th, + td { + border: 0; + } + + &-calendar-table { + border-spacing: 0; + margin-bottom: 0; + } + + &-column-header { + line-height: 18px; + width: 33px; + padding: 6px 0; + text-align: center; + .@{calendar-prefix-cls}-column-header-inner { + display: block; + font-weight: normal; + } + } + + &-week-number-header { + .@{calendar-prefix-cls}-column-header-inner { + display: none; + } + } + + &-cell { + padding: 4px 0; + } + + &-date { + display: block; + margin: 0 auto; + color: @text-color; + border-radius: @border-radius-sm; + width: 20px; + height: 20px; + line-height: 18px; + border: @border-width-base @border-style-base transparent; + padding: 0; + background: transparent; + text-align: center; + transition: background 0.3s ease; + + &-panel { + position: relative; + } + + &:hover { + background: @item-hover-bg; + cursor: pointer; + } + + &:active { + color: #fff; + background: @primary-5; + } + } + + &-today &-date { + border-color: @primary-color; + font-weight: bold; + color: @primary-color; + } + + &-last-month-cell &-date, + &-next-month-btn-day &-date { + color: @disabled-color; + } + + &-selected-day &-date { + background: @primary-color; + color: #fff; + border: @border-width-base @border-style-base transparent; + + &:hover { + background: @primary-color; + } + } + + &-disabled-cell &-date { + cursor: not-allowed; + color: #bcbcbc; + background: @disabled-bg; + border-radius: 0; + width: auto; + border: @border-width-base @border-style-base transparent; + + &:hover { + background: @disabled-bg; + } + } + &-disabled-cell&-today &-date { + position: relative; + margin-right: 5px; + padding-left: 5px; + &:before { + content: " "; + position: absolute; + top: -1px; + left: 5px; + width: 20px; + height: 20px; + border: @border-width-base @border-style-base #bcbcbc; + border-radius: @border-radius-base; + } + } + + &-disabled-cell-first-of-row &-date { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + &-disabled-cell-last-of-row &-date { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + &-footer { + border-top: @border-width-base @border-style-base @border-color-split; + line-height: 38px; + padding: 0 12px; + &:empty { + border-top: 0; + } + &-btn { + text-align: center; + display: block; + } + &-extra + &-btn { + border-top: @border-width-base @border-style-base @border-color-split; + margin: 0 -12px; + padding: 0 12px; + } + } + + .@{calendar-prefix-cls}-today-btn, + .@{calendar-prefix-cls}-clear-btn { + display: inline-block; + text-align: center; + margin: 0 0 0 8px; + &-disabled { + color: @disabled-color; + cursor: not-allowed; + } + &:only-child { + margin: 0; + } + } + + .@{calendar-prefix-cls}-clear-btn { + display: none; + position: absolute; + right: 5px; + text-indent: -76px; + overflow: hidden; + width: 20px; + height: 20px; + text-align: center; + line-height: 20px; + top: 7px; + margin: 0; + } + + .@{calendar-prefix-cls}-clear-btn:after { + .iconfont-font("\e62e"); + font-size: @font-size-base; + color: @disabled-color; + display: inline-block; + line-height: 1; + width: 20px; + text-indent: 43px; + transition: color 0.3s ease; + } + + .@{calendar-prefix-cls}-clear-btn:hover:after { + color: @text-color-secondary; + } + + .@{calendar-prefix-cls}-ok-btn { + .btn; + .btn-primary; + .button-size(@btn-height-sm; @btn-padding-sm; @font-size-base; @border-radius-base); + line-height: @line-height-base; + + &-disabled { + .button-color(@btn-disable-color; @btn-disable-bg; @btn-disable-border); + cursor: not-allowed; + &:hover { + .button-color(@btn-disable-color; @btn-disable-bg; @btn-disable-border); + } + } + } +} diff --git a/src/components/datepicker/style/DecadePanel.less b/src/components/datepicker/style/DecadePanel.less new file mode 100755 index 00000000000..a019c249107 --- /dev/null +++ b/src/components/datepicker/style/DecadePanel.less @@ -0,0 +1,71 @@ +.@{calendar-prefix-cls}-decade-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + background: @component-background; + border-radius: @border-radius-base; + outline: none; +} + +.@{calendar-prefix-cls}-decade-panel-hidden { + display: none; +} + +.@{calendar-prefix-cls}-decade-panel-header { + .calendarPanelHeader(~"@{calendar-prefix-cls}-decade-panel"); +} + +.@{calendar-prefix-cls}-decade-panel-body { + height: ~"calc(100% - 34px)"; +} + +.@{calendar-prefix-cls}-decade-panel-table { + table-layout: fixed; + width: 100%; + height: 100%; + border-collapse: separate; +} + +.@{calendar-prefix-cls}-decade-panel-cell { + text-align: center; + white-space: nowrap; +} + +.@{calendar-prefix-cls}-decade-panel-decade { + display: inline-block; + margin: 0 auto; + color: @text-color; + background: transparent; + text-align: center; + height: 24px; + line-height: 24px; + padding: 0 6px; + border-radius: 4px; + transition: background 0.3s ease; + + &:hover { + background: @item-hover-bg; + cursor: pointer; + } +} + +.@{calendar-prefix-cls}-decade-panel-selected-cell .@{calendar-prefix-cls}-decade-panel-decade { + background: @primary-color; + color: #fff; + + &:hover { + background: @primary-color; + color: #fff; + } +} + +.@{calendar-prefix-cls}-decade-panel-last-century-cell, +.@{calendar-prefix-cls}-decade-panel-next-century-cell { + .@{calendar-prefix-cls}-decade-panel-decade { + user-select: none; + color: @disabled-color; + } +} diff --git a/src/components/datepicker/style/MonthPanel.less b/src/components/datepicker/style/MonthPanel.less new file mode 100755 index 00000000000..d1986a81f19 --- /dev/null +++ b/src/components/datepicker/style/MonthPanel.less @@ -0,0 +1,75 @@ +.@{calendar-prefix-cls}-month-panel { + position: absolute; + top: 1px; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + border-radius: @border-radius-base; + background: @component-background; + outline: none; + + > div { // TODO: this is a useless wrapper, and we need to remove it in rc-calendar + height: 100%; + } +} + +.@{calendar-prefix-cls}-month-panel-hidden { + display: none; +} + +.@{calendar-prefix-cls}-month-panel-header { + .calendarPanelHeader(~"@{calendar-prefix-cls}-month-panel"); +} + +.@{calendar-prefix-cls}-month-panel-body { + height: ~"calc(100% - 34px)"; +} + +.@{calendar-prefix-cls}-month-panel-table { + table-layout: fixed; + width: 100%; + height: 100%; + border-collapse: separate; +} + +.@{calendar-prefix-cls}-month-panel-selected-cell .@{calendar-prefix-cls}-month-panel-month { + background: @primary-color; + color: #fff; + + &:hover { + background: @primary-color; + color: #fff; + } +} + +.@{calendar-prefix-cls}-month-panel-cell { + text-align: center; + + &-disabled .@{calendar-prefix-cls}-month-panel-month { + &, + &:hover { + cursor: not-allowed; + color: #bcbcbc; + background: @disabled-bg; + } + } +} + +.@{calendar-prefix-cls}-month-panel-month { + display: inline-block; + margin: 0 auto; + color: @text-color; + background: transparent; + text-align: center; + height: 24px; + line-height: 24px; + padding: 0 6px; + border-radius: 4px; + transition: background 0.3s ease; + + &:hover { + background: @item-hover-bg; + cursor: pointer; + } +} diff --git a/src/components/datepicker/style/MonthPicker.less b/src/components/datepicker/style/MonthPicker.less new file mode 100755 index 00000000000..a6a7f7a69b9 --- /dev/null +++ b/src/components/datepicker/style/MonthPicker.less @@ -0,0 +1,7 @@ +.@{calendar-prefix-cls}-month { + .@{calendar-prefix-cls}-month-panel, + .@{calendar-prefix-cls}-year-panel { + top: 0; + height: 248px; + } +} diff --git a/src/components/datepicker/style/Picker.less b/src/components/datepicker/style/Picker.less new file mode 100755 index 00000000000..9b0fb5b9161 --- /dev/null +++ b/src/components/datepicker/style/Picker.less @@ -0,0 +1,88 @@ +.@{calendar-prefix-cls}-picker-container { + position: absolute; + z-index: @zindex-picker; + + &.slide-up-enter.slide-up-enter-active&-placement-topLeft, + &.slide-up-enter.slide-up-enter-active&-placement-topRight, + &.slide-up-appear.slide-up-appear-active&-placement-topLeft, + &.slide-up-appear.slide-up-appear-active&-placement-topRight { + animation-name: antSlideDownIn; + } + + &.slide-up-enter.slide-up-enter-active&-placement-bottomLeft, + &.slide-up-enter.slide-up-enter-active&-placement-bottomRight, + &.slide-up-appear.slide-up-appear-active&-placement-bottomLeft, + &.slide-up-appear.slide-up-appear-active&-placement-bottomRight { + animation-name: antSlideUpIn; + } + + &.slide-up-leave.slide-up-leave-active&-placement-topLeft, + &.slide-up-leave.slide-up-leave-active&-placement-topRight { + animation-name: antSlideDownOut; + } + + &.slide-up-leave.slide-up-leave-active&-placement-bottomLeft, + &.slide-up-leave.slide-up-leave-active&-placement-bottomRight { + animation-name: antSlideUpOut; + } +} + +.@{calendar-prefix-cls}-picker { + position: relative; + display: inline-block; + outline: none; + font-size: @font-size-base; + transition: opacity 0.3s; + + &-input { + outline: none; + display: block; + } + + &:hover &-input:not(.@{ant-prefix}-input-disabled) { + border-color: @primary-color; + } + + &-clear, + &-icon { + position: absolute; + width: 14px; + height: 14px; + right: 8px; + top: 50%; + margin-top: -7px; + line-height: 14px; + font-size: @font-size-base; + transition: all .3s; + user-select: none; + } + + &-clear { + opacity: 0; + z-index: 1; + color: @disabled-color; + background: @input-bg; + pointer-events: none; + cursor: pointer; + &:hover { + color: @text-color-secondary; + } + } + + &:hover &-clear { + opacity: 1; + pointer-events: auto; + } + + &-icon { + color: @text-color-secondary; + &:after { + content: "\e6bb"; + font-family: "anticon"; + font-size: @font-size-base; + color: @text-color-secondary; + display: inline-block; + line-height: 1; + } + } +} diff --git a/src/components/datepicker/style/RangePicker.less b/src/components/datepicker/style/RangePicker.less new file mode 100755 index 00000000000..18fd5d68e71 --- /dev/null +++ b/src/components/datepicker/style/RangePicker.less @@ -0,0 +1,149 @@ +.@{calendar-timepicker-prefix-cls} { + position: absolute; + width: 100%; + top: 34px; + background-color: @component-background; + + &-panel { + z-index: @zindex-picker; + position: absolute; + width: 100%; + } + + &-inner { + display: inline-block; + position: relative; + outline: none; + list-style: none; + font-size: @font-size-base; + text-align: left; + background-color: @component-background; + background-clip: padding-box; + line-height: 1.5; + overflow: hidden; + width: 100%; + } + &-combobox { + width: 100%; + } + + &-column-1, + &-column-1 &-select { + width: 100%; + } + &-column-2 &-select { + width: 50%; + } + &-column-3 &-select { + width: 33.33%; + } + &-column-4 &-select { + width: 25%; + } + + &-input-wrap { + display: none; + } + + &-select { + float: left; + font-size: @font-size-base; + border-right: @border-width-base @border-style-base @border-color-split; + box-sizing: border-box; + overflow: hidden; + position: relative; // Fix chrome weird render bug + height: 206px; + + &:hover { + overflow-y: auto; + } + + &:first-child { + border-left: 0; + margin-left: 0; + } + + &:last-child { + border-right: 0; + } + + ul { + list-style: none; + box-sizing: border-box; + margin: 0; + padding: 0; + width: 100%; + max-height: 206px; + } + + li { + text-align: center; + list-style: none; + box-sizing: content-box; + margin: 0; + width: 100%; + height: 24px; + line-height: 24px; + cursor: pointer; + user-select: none; + transition: background 0.3s ease; + } + + li:last-child:after { + content: ''; + height: 182px; + display: block; + } + + li:hover { + background: @item-hover-bg; + } + + li&-option-selected { + background: @time-picker-selected-bg; + font-weight: bold; + } + + li&-option-disabled { + color: @btn-disable-color; + &:hover { + background: transparent; + cursor: not-allowed; + } + } + } +} + +.@{calendar-prefix-cls}-time { + .@{calendar-prefix-cls}-day-select { + padding: 0 2px; + font-weight: bold; + display: inline-block; + color: @text-color; + line-height: 34px; + } + + .@{calendar-prefix-cls}-footer { + position: relative; + height: auto; + line-height: auto; + + &-btn { + text-align: right; + } + + .@{calendar-prefix-cls}-today-btn { + float: left; + margin: 0; + } + + .@{calendar-prefix-cls}-time-picker-btn { + display: inline-block; + margin-right: 8px; + + &-disabled { + color: @disabled-color; + } + } + } +} diff --git a/src/components/datepicker/style/TimePicker.less b/src/components/datepicker/style/TimePicker.less new file mode 100755 index 00000000000..18fd5d68e71 --- /dev/null +++ b/src/components/datepicker/style/TimePicker.less @@ -0,0 +1,149 @@ +.@{calendar-timepicker-prefix-cls} { + position: absolute; + width: 100%; + top: 34px; + background-color: @component-background; + + &-panel { + z-index: @zindex-picker; + position: absolute; + width: 100%; + } + + &-inner { + display: inline-block; + position: relative; + outline: none; + list-style: none; + font-size: @font-size-base; + text-align: left; + background-color: @component-background; + background-clip: padding-box; + line-height: 1.5; + overflow: hidden; + width: 100%; + } + &-combobox { + width: 100%; + } + + &-column-1, + &-column-1 &-select { + width: 100%; + } + &-column-2 &-select { + width: 50%; + } + &-column-3 &-select { + width: 33.33%; + } + &-column-4 &-select { + width: 25%; + } + + &-input-wrap { + display: none; + } + + &-select { + float: left; + font-size: @font-size-base; + border-right: @border-width-base @border-style-base @border-color-split; + box-sizing: border-box; + overflow: hidden; + position: relative; // Fix chrome weird render bug + height: 206px; + + &:hover { + overflow-y: auto; + } + + &:first-child { + border-left: 0; + margin-left: 0; + } + + &:last-child { + border-right: 0; + } + + ul { + list-style: none; + box-sizing: border-box; + margin: 0; + padding: 0; + width: 100%; + max-height: 206px; + } + + li { + text-align: center; + list-style: none; + box-sizing: content-box; + margin: 0; + width: 100%; + height: 24px; + line-height: 24px; + cursor: pointer; + user-select: none; + transition: background 0.3s ease; + } + + li:last-child:after { + content: ''; + height: 182px; + display: block; + } + + li:hover { + background: @item-hover-bg; + } + + li&-option-selected { + background: @time-picker-selected-bg; + font-weight: bold; + } + + li&-option-disabled { + color: @btn-disable-color; + &:hover { + background: transparent; + cursor: not-allowed; + } + } + } +} + +.@{calendar-prefix-cls}-time { + .@{calendar-prefix-cls}-day-select { + padding: 0 2px; + font-weight: bold; + display: inline-block; + color: @text-color; + line-height: 34px; + } + + .@{calendar-prefix-cls}-footer { + position: relative; + height: auto; + line-height: auto; + + &-btn { + text-align: right; + } + + .@{calendar-prefix-cls}-today-btn { + float: left; + margin: 0; + } + + .@{calendar-prefix-cls}-time-picker-btn { + display: inline-block; + margin-right: 8px; + + &-disabled { + color: @disabled-color; + } + } + } +} diff --git a/src/components/datepicker/style/YearPanel.less b/src/components/datepicker/style/YearPanel.less new file mode 100755 index 00000000000..a47d9ca60aa --- /dev/null +++ b/src/components/datepicker/style/YearPanel.less @@ -0,0 +1,74 @@ +.@{calendar-prefix-cls}-year-panel { + position: absolute; + top: 1px; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + border-radius: @border-radius-base; + background: @component-background; + outline: none; + + > div { // TODO: this is a useless wrapper, and we need to remove it in rc-calendar + height: 100%; + } +} + +.@{calendar-prefix-cls}-year-panel-hidden { + display: none; +} + +.@{calendar-prefix-cls}-year-panel-header { + .calendarPanelHeader(~"@{calendar-prefix-cls}-year-panel"); +} + +.@{calendar-prefix-cls}-year-panel-body { + height: ~"calc(100% - 34px)"; +} + +.@{calendar-prefix-cls}-year-panel-table { + table-layout: fixed; + width: 100%; + height: 100%; + border-collapse: separate; +} + +.@{calendar-prefix-cls}-year-panel-cell { + text-align: center; +} + +.@{calendar-prefix-cls}-year-panel-year { + display: inline-block; + margin: 0 auto; + color: @text-color; + background: transparent; + text-align: center; + height: 24px; + line-height: 24px; + padding: 0 6px; + border-radius: 4px; + transition: background 0.3s ease; + + &:hover { + background: @item-hover-bg; + cursor: pointer; + } +} + +.@{calendar-prefix-cls}-year-panel-selected-cell .@{calendar-prefix-cls}-year-panel-year { + background: @primary-color; + color: #fff; + + &:hover { + background: @primary-color; + color: #fff; + } +} + +.@{calendar-prefix-cls}-year-panel-last-decade-cell, +.@{calendar-prefix-cls}-year-panel-next-decade-cell { + .@{calendar-prefix-cls}-year-panel-year { + user-select: none; + color: @disabled-color; + } +} diff --git a/src/components/datepicker/style/index.less b/src/components/datepicker/style/index.less new file mode 100755 index 00000000000..37139915a32 --- /dev/null +++ b/src/components/datepicker/style/index.less @@ -0,0 +1,16 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; +@import "../../input/style/mixin"; +@import "../../button/style/mixin"; + +@calendar-prefix-cls: ~"@{ant-prefix}-calendar"; +@calendar-timepicker-prefix-cls: ~"@{ant-prefix}-calendar-time-picker"; + +@import "Picker"; +@import "Calendar"; +@import "RangePicker"; +@import "TimePicker"; +@import "MonthPanel"; +@import "YearPanel"; +@import "DecadePanel"; +@import "MonthPicker"; diff --git a/src/components/datepicker/style/patch.less b/src/components/datepicker/style/patch.less new file mode 100644 index 00000000000..73a9ee1484a --- /dev/null +++ b/src/components/datepicker/style/patch.less @@ -0,0 +1,16 @@ +nz-datepicker { + display: inline-block; + position: relative; +} + +@import "./index.less"; +.@{calendar-prefix-cls}-picker-container { + position: relative; + left: -2px; + &.top { + bottom: -2px; + } + &.bottom { + top: -2px; + } +} diff --git a/src/components/dropdown/nz-dropdown-button.component.ts b/src/components/dropdown/nz-dropdown-button.component.ts new file mode 100644 index 00000000000..049d63a82ed --- /dev/null +++ b/src/components/dropdown/nz-dropdown-button.component.ts @@ -0,0 +1,96 @@ +import { + Component, + ViewEncapsulation, + OnInit, + OnDestroy, + Input, + ViewChild, + Output, + EventEmitter, + AfterViewInit +} from '@angular/core'; +import { DropDownAnimation } from '../core/animation/dropdown-animations'; +import { NzDropDownDirective } from './nz-dropdown.directive'; +import { NzDropDownComponent } from './nz-dropdown.component'; + +@Component({ + selector : 'nz-dropdown-button', + encapsulation: ViewEncapsulation.None, + animations : [ + DropDownAnimation + ], + template : ` +
+ +
+ +
+ +
+
`, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) + +export class NzDropDownButtonComponent extends NzDropDownComponent implements OnInit, OnDestroy, AfterViewInit { + @Input() nzDisable = false; + @Input() nzSize = 'default'; + @Input() nzType = 'default'; + @ViewChild('content') content; + @Output() nzClick = new EventEmitter(); + @ViewChild(NzDropDownDirective) _nzOrigin; + + ngOnInit() { + this._$mouseSubject.debounceTime(300).subscribe((data: boolean) => { + if (this.nzDisable) { + return; + } + this.nzVisible = data; + if (this.nzVisible) { + if (!this._triggerWidth) { + this._setTriggerWidth(); + } + } + this.nzVisibleChange.emit(this.nzVisible); + }); + this._nzMenu.setDropDown(true); + } + + /** rewrite afterViewInit hook */ + ngAfterViewInit() { + + } +} diff --git a/src/components/dropdown/nz-dropdown.component.spec.ts b/src/components/dropdown/nz-dropdown.component.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/dropdown/nz-dropdown.component.ts b/src/components/dropdown/nz-dropdown.component.ts new file mode 100644 index 00000000000..71b9992f557 --- /dev/null +++ b/src/components/dropdown/nz-dropdown.component.ts @@ -0,0 +1,159 @@ +import { + Component, + ViewEncapsulation, + OnInit, + OnDestroy, + Input, + Renderer2, + ContentChild, + Output, + EventEmitter, AfterViewInit +} from '@angular/core'; +import { Subject } from 'rxjs/Rx'; +import { NzMenuComponent } from '../menu/nz-menu.component'; +import { DropDownAnimation } from '../core/animation/dropdown-animations'; +import { NzDropDownDirective } from './nz-dropdown.directive'; +import { POSITION_MAP, DEFAULT_DROPDOWN_POSITIONS } from '../core/overlay/overlay-position-map'; +import { ConnectionPositionPair } from '../core/overlay'; + +export type NzPlacement = 'bottomLeft' | 'bottomCenter' | 'bottomRight' | 'topLeft' | 'topCenter' | 'topRight'; + +@Component({ + selector : 'nz-dropdown', + encapsulation: ViewEncapsulation.None, + animations : [ + DropDownAnimation + ], + template : ` +
+ +
+ +
+ +
+
`, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) + +export class NzDropDownComponent implements OnInit, OnDestroy, AfterViewInit { + _triggerWidth = 0; + _$mouseSubject = new Subject(); + _placement: NzPlacement = 'bottomLeft'; + _dropDownPosition: 'top' | 'bottom' = 'bottom'; + _positions: ConnectionPositionPair[] = [ ...DEFAULT_DROPDOWN_POSITIONS ]; + @ContentChild(NzDropDownDirective) _nzOrigin; + @ContentChild(NzMenuComponent) _nzMenu; + @Input() nzTrigger: 'click' | 'hover' = 'hover'; + @Input() nzClickHide = true; + @Input() nzVisible = false; + @Output() nzVisibleChange: EventEmitter = new EventEmitter(); + + @Input() + set nzPlacement(value: NzPlacement) { + this._placement = value; + this._dropDownPosition = (this.nzPlacement.indexOf('top') !== -1) ? 'top' : 'bottom'; + this._positions.unshift(POSITION_MAP[ this._placement ] as ConnectionPositionPair); + }; + + get nzPlacement(): NzPlacement { + return this._placement; + } + + _onClickEvent() { + if (this.nzTrigger === 'click') { + this._show(); + } + } + + _onMouseEnterEvent(e) { + if (this.nzTrigger === 'hover') { + this._show(); + } + } + + _onMouseLeaveEvent(e) { + if (this.nzTrigger === 'hover') { + this._hide(); + } + } + + _onPositionChange(position) { + this._dropDownPosition = position.connectionPair.originY; + } + + _clickDropDown($event) { + $event.stopPropagation(); + if (this.nzClickHide) { + this.nzVisible = false; + } + } + + _setTriggerWidth(): void { + this._triggerWidth = this._nzOrigin.elementRef.nativeElement.getBoundingClientRect().width; + } + + _show() { + this._$mouseSubject.next(true); + } + + _hide() { + this._$mouseSubject.next(false); + } + + ngOnInit() { + this._$mouseSubject.debounceTime(300).subscribe((data: boolean) => { + this.nzVisible = data; + if (this.nzVisible) { + if (!this._triggerWidth) { + this._setTriggerWidth(); + } + } + this.nzVisibleChange.emit(this.nzVisible); + }); + this._nzMenu.setDropDown(true); + } + + ngOnDestroy() { + this._$mouseSubject.unsubscribe(); + } + + + ngAfterViewInit() { + if (this.nzTrigger === 'hover') { + this._renderer.listen(this._nzOrigin.elementRef.nativeElement, 'mouseenter', () => this._show()); + this._renderer.listen(this._nzOrigin.elementRef.nativeElement, 'mouseleave', () => this._hide()); + } + if (this.nzTrigger === 'click') { + this._renderer.listen(this._nzOrigin.elementRef.nativeElement, 'click', (e) => { + e.preventDefault(); + this._show() + }); + } + } + + get _hasBackdrop() { + return this.nzTrigger === 'click'; + } + + constructor(private _renderer: Renderer2) { + } +} diff --git a/src/components/dropdown/nz-dropdown.directive.ts b/src/components/dropdown/nz-dropdown.directive.ts new file mode 100644 index 00000000000..56dde1da3bf --- /dev/null +++ b/src/components/dropdown/nz-dropdown.directive.ts @@ -0,0 +1,8 @@ +import { Directive, ElementRef } from '@angular/core'; +@Directive({ + selector: '[nz-dropdown]', +}) +export class NzDropDownDirective { + constructor(public elementRef: ElementRef) { + } +} diff --git a/src/components/dropdown/nz-dropdown.module.ts b/src/components/dropdown/nz-dropdown.module.ts new file mode 100644 index 00000000000..0293edd1589 --- /dev/null +++ b/src/components/dropdown/nz-dropdown.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { OverlayModule } from '../core/overlay'; +import { NzButtonModule } from '../button/nz-button.module'; + +import { NzDropDownComponent } from './nz-dropdown.component'; +import { NzDropDownDirective } from './nz-dropdown.directive'; +import { NzDropDownButtonComponent } from './nz-dropdown-button.component'; +import { NzMenuModule } from '../menu/nz-menu.module'; + +@NgModule({ + imports : [ CommonModule, OverlayModule, FormsModule, NzButtonModule, NzMenuModule ], + declarations: [ NzDropDownComponent, NzDropDownButtonComponent, NzDropDownDirective ], + exports : [ NzDropDownComponent, NzDropDownButtonComponent, NzDropDownDirective ] +}) +export class NzDropDownModule { +} diff --git a/src/components/dropdown/style/index.less b/src/components/dropdown/style/index.less new file mode 100755 index 00000000000..2d94129c243 --- /dev/null +++ b/src/components/dropdown/style/index.less @@ -0,0 +1,230 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; + +@dropdown-prefix-cls: ~"@{ant-prefix}-dropdown"; + +.@{dropdown-prefix-cls} { + position: absolute; + left: -9999px; + top: -9999px; + z-index: @zindex-dropdown; + display: block; + font-size: @font-size-base; + font-weight: normal; + line-height: 1.5; + + &-wrap { + position: relative; + + .@{ant-prefix}-btn > .@{iconfont-css-prefix}-down { + .iconfont-size-under-12px(10px); + } + + .@{iconfont-css-prefix}-down:before { + transition: transform 0.2s ease; + } + } + + &-wrap-open { + .@{iconfont-css-prefix}-down:before { + transform: rotate(180deg); + } + } + + &-hidden, + &-menu-hidden { + display: none; + } + + &-menu { + outline: none; + position: relative; + list-style-type: none; + padding: 0; + margin: 0; + text-align: left; + background-color: @component-background; + border-radius: @border-radius-base; + box-shadow: @box-shadow-base; + background-clip: padding-box; + + &-item, + &-submenu-title { + padding: 7px 8px; + margin: 0; + clear: both; + font-size: @font-size-base; + font-weight: normal; + color: @text-color; + white-space: nowrap; + cursor: pointer; + transition: all .3s; + + > a { + color: @text-color; + display: block; + padding: 7px 8px; + margin: -7px -8px; + transition: all .3s; + &:focus { + text-decoration: none; + } + } + + &-selected, + &-selected > a { + color: @primary-color; + background-color: @item-active-bg; + } + + &:hover { + background-color: @item-hover-bg; + } + + &-disabled { + color: @disabled-color; + cursor: not-allowed; + + &:hover { + color: @disabled-color; + background-color: @component-background; + cursor: not-allowed; + } + } + + &:first-child, + &:first-child > a { + border-radius: @border-radius-base @border-radius-base 0 0; + } + + &:last-child, + &:last-child > a { + border-radius: 0 0 @border-radius-base @border-radius-base; + } + + &:only-child, + &:only-child > a { + border-radius: @border-radius-base; + } + + &-divider { + height: 1px; + overflow: hidden; + background-color: @border-color-split; + line-height: 0; + } + } + + &-submenu-title:after { + font-family: "anticon" !important; + position: absolute; + content: "\e61f"; + right: 8px; + color: @text-color-secondary; + .iconfont-size-under-12px(10px); + } + + &-submenu-vertical { + position: relative; + } + + &-submenu-vertical > & { + top: 0; + left: 100%; + position: absolute; + min-width: 100%; + margin-left: 4px; + transform-origin: 0 0; + } + + &-submenu&-submenu-disabled .@{dropdown-prefix-cls}-menu-submenu-title { + &, + &:after { + color: @disabled-color; + } + } + &-submenu:first-child &-submenu-title { + border-radius: @border-radius-base @border-radius-base 0 0; + } + + &-submenu:last-child &-submenu-title { + border-radius: 0 0 @border-radius-base @border-radius-base; + } + } + + &.slide-down-enter.slide-down-enter-active&-placement-bottomLeft, + &.slide-down-appear.slide-down-appear-active&-placement-bottomLeft, + &.slide-down-enter.slide-down-enter-active&-placement-bottomCenter, + &.slide-down-appear.slide-down-appear-active&-placement-bottomCenter, + &.slide-down-enter.slide-down-enter-active&-placement-bottomRight, + &.slide-down-appear.slide-down-appear-active&-placement-bottomRight { + animation-name: antSlideUpIn; + } + + &.slide-up-enter.slide-up-enter-active&-placement-topLeft, + &.slide-up-appear.slide-up-appear-active&-placement-topLeft, + &.slide-up-enter.slide-up-enter-active&-placement-topCenter, + &.slide-up-appear.slide-up-appear-active&-placement-topCenter, + &.slide-up-enter.slide-up-enter-active&-placement-topRight, + &.slide-up-appear.slide-up-appear-active&-placement-topRight { + animation-name: antSlideDownIn; + } + + &.slide-down-leave.slide-down-leave-active&-placement-bottomLeft, + &.slide-down-leave.slide-down-leave-active&-placement-bottomCenter, + &.slide-down-leave.slide-down-leave-active&-placement-bottomRight { + animation-name: antSlideUpOut; + } + + &.slide-up-leave.slide-up-leave-active&-placement-topLeft, + &.slide-up-leave.slide-up-leave-active&-placement-topCenter, + &.slide-up-leave.slide-up-leave-active&-placement-topRight { + animation-name: antSlideDownOut; + } +} + +.@{dropdown-prefix-cls}-trigger, +.@{dropdown-prefix-cls}-link { + .@{iconfont-css-prefix}-down { + .iconfont-size-under-12px(10px); + } +} + +.@{dropdown-prefix-cls}-button { + white-space: nowrap; + + &.@{ant-prefix}-btn-group > .@{ant-prefix}-btn:last-child:not(:first-child) { + padding-right: 8px; + } + .@{iconfont-css-prefix}-down { + .iconfont-size-under-12px(10px); + } +} + +// https://github.com/ant-design/ant-design/issues/4903 +.@{dropdown-prefix-cls}-menu-dark { + &, + .@{dropdown-prefix-cls}-menu { + background: @menu-dark-bg; + } + .@{dropdown-prefix-cls}-menu-item, + .@{dropdown-prefix-cls}-menu-submenu-title, + .@{dropdown-prefix-cls}-menu-item > a { + color: @text-color-secondary-dark; + &:after { + color: @text-color-secondary-dark; + } + &:hover { + color: #fff; + background: transparent; + } + } + .@{dropdown-prefix-cls}-menu-item-selected { + &, + &:hover, + > a { + background: @primary-color; + color: #fff; + } + } +} diff --git a/src/components/dropdown/style/patch.less b/src/components/dropdown/style/patch.less new file mode 100644 index 00000000000..76bb73b9fad --- /dev/null +++ b/src/components/dropdown/style/patch.less @@ -0,0 +1,21 @@ +@import "./index"; + +nz-dropdown { + position: relative; + display: inline-block; +} + +nz-dropdown-button { + position: relative; + display: inline-block; +} + +// +.@{dropdown-prefix-cls} { + top: 100%; + left: 0; + position: relative; + width: 100%; + margin-top: 4px; + margin-bottom: 4px; +} diff --git a/src/components/form/nz-form-control.component.ts b/src/components/form/nz-form-control.component.ts new file mode 100644 index 00000000000..a25af5bf254 --- /dev/null +++ b/src/components/form/nz-form-control.component.ts @@ -0,0 +1,68 @@ +import { Component, HostBinding, Input } from '@angular/core'; + +@Component({ + selector: '[nz-form-control]', + template: ` +
+ +
+ `, + styles : [] +}) + +export class NzFormControlComponent { + _hasFeedback = false; + @HostBinding(`class.ant-form-item-control-wrapper`) true; + + @Input() + set nzHasFeedback(value: boolean|string) { + if (value === '') { + this._hasFeedback = true; + } else { + this._hasFeedback = value as boolean; + } + } + + get nzHasFeedback() { + return this._hasFeedback; + } + + @Input() nzValidateStatus; + + get isWarning(): boolean { + return this._isDirtyAndError('warning'); + }; + + get isValidate(): boolean { + return this._isDirtyAndError('validating') || this.nzValidateStatus === 'pending' || this.nzValidateStatus && this.nzValidateStatus.dirty && this.nzValidateStatus.pending; + }; + + get isError(): boolean { + return this._isDirtyAndError('error') + || this._isDirtyAndError('required') + || this._isDirtyAndError('pattern') + || this._isDirtyAndError('email') + || this._isDirtyAndError('maxlength') + || this._isDirtyAndError('minlength') + }; + + get isSuccess(): boolean { + return this.nzValidateStatus === 'success' || this.nzValidateStatus && this.nzValidateStatus.dirty && this.nzValidateStatus.valid; + }; + + get hasFeedBack(): boolean { + return this.nzHasFeedback as boolean; + }; + + _isDirtyAndError(name) { + return this.nzValidateStatus === name || this.nzValidateStatus && this.nzValidateStatus.dirty && this.nzValidateStatus.hasError && this.nzValidateStatus.hasError(name) + } + + constructor() { + } +} diff --git a/src/components/form/nz-form-explain.directive.ts b/src/components/form/nz-form-explain.directive.ts new file mode 100644 index 00000000000..1ad6cd8b040 --- /dev/null +++ b/src/components/form/nz-form-explain.directive.ts @@ -0,0 +1,26 @@ +import { Component, HostBinding, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { NzFormItemDirective } from './nz-form-item.directive'; + +@Component({ + selector : '[nz-form-explain]', + encapsulation: ViewEncapsulation.None, + template : ` + + `, + styles : [] +}) + +export class NzFormExplainComponent implements OnDestroy, OnInit { + @HostBinding(`class.ant-form-explain`) true; + + constructor(private _nzFormItem: NzFormItemDirective) { + } + + ngOnDestroy(): any { + this._nzFormItem.disableHelp(); + } + + ngOnInit() { + this._nzFormItem.enableHelp(); + } +} diff --git a/src/components/form/nz-form-extra.directive.ts b/src/components/form/nz-form-extra.directive.ts new file mode 100644 index 00000000000..d6899c18dec --- /dev/null +++ b/src/components/form/nz-form-extra.directive.ts @@ -0,0 +1,9 @@ +import { Directive, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[nz-form-extra]' +}) + +export class NzFormExtraDirective { + @HostBinding(`class.ant-form-extra`) true; +} diff --git a/src/components/form/nz-form-item-required.directive.ts b/src/components/form/nz-form-item-required.directive.ts new file mode 100644 index 00000000000..1ba280aa55a --- /dev/null +++ b/src/components/form/nz-form-item-required.directive.ts @@ -0,0 +1,9 @@ +import { Directive, HostBinding, Input } from '@angular/core'; + +@Directive({ + selector: '[nz-form-item-required]' +}) + +export class NzFormItemRequiredDirective { + @Input() @HostBinding(`class.ant-form-item-required`) nzRequired = true; +} diff --git a/src/components/form/nz-form-item.directive.ts b/src/components/form/nz-form-item.directive.ts new file mode 100644 index 00000000000..666d1536f98 --- /dev/null +++ b/src/components/form/nz-form-item.directive.ts @@ -0,0 +1,27 @@ +import { Directive, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[nz-form-item]' +}) + +export class NzFormItemDirective { + _withHelp = 0; + + @HostBinding(`class.ant-form-item`) true; + + enableHelp() { + this._withHelp++; + }; + + disableHelp() { + this._withHelp--; + }; + + @HostBinding(`class.ant-form-item-with-help`) + get withHelp(): boolean { + return this._withHelp > 0; + }; + + constructor() { + } +} diff --git a/src/components/form/nz-form-label.directive.ts b/src/components/form/nz-form-label.directive.ts new file mode 100644 index 00000000000..3ac8e0bb2bf --- /dev/null +++ b/src/components/form/nz-form-label.directive.ts @@ -0,0 +1,9 @@ +import { Directive, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[nz-form-label]' +}) + +export class NzFormLabelDirective { + @HostBinding(`class.ant-form-item-label`) true; +} diff --git a/src/components/form/nz-form-split.directive.ts b/src/components/form/nz-form-split.directive.ts new file mode 100644 index 00000000000..19a64cc6c9d --- /dev/null +++ b/src/components/form/nz-form-split.directive.ts @@ -0,0 +1,9 @@ +import { Directive, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[nz-form-split]' +}) + +export class NzFormSplitDirective { + @HostBinding(`class.ant-form-split`) true; +} diff --git a/src/components/form/nz-form-text.directive.ts b/src/components/form/nz-form-text.directive.ts new file mode 100644 index 00000000000..90bcfaa9ec0 --- /dev/null +++ b/src/components/form/nz-form-text.directive.ts @@ -0,0 +1,9 @@ +import { Directive, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[nz-form-text]' +}) + +export class NzFormTextDirective { + @HostBinding(`class.ant-form-text`) true; +} diff --git a/src/components/form/nz-form.component.ts b/src/components/form/nz-form.component.ts new file mode 100644 index 00000000000..ddd252e6bc1 --- /dev/null +++ b/src/components/form/nz-form.component.ts @@ -0,0 +1,50 @@ +import { Component, Input, OnInit, ElementRef, ViewEncapsulation, Renderer2 } from '@angular/core'; + +@Component({ + selector : '[nz-form]', + encapsulation: ViewEncapsulation.None, + template : ` + + `, + styleUrls : [ + './style/index.less', + './style/patch.less' + ] +}) + +export class NzFormComponent implements OnInit { + _classList: Array = []; + _el: HTMLElement; + _prefixCls = 'ant-form'; + + /** @deprecated Use `nzLayout` instead. */ + @Input() nzType = 'horizontal'; + + @Input() + set nzLayout(value) { + this.nzType = value; + this.setClassMap(); + } + + setClassMap(): void { + this._classList.forEach(_className => { + this._renderer.removeClass(this._el, _className); + }); + this._classList = [ + this.nzType && `${this._prefixCls}-${this.nzType}` + ].filter((item) => { + return !!item; + }); + this._classList.forEach(_className => { + this._renderer.addClass(this._el, _className); + }) + } + + constructor(private _elementRef: ElementRef, private _renderer: Renderer2) { + this._el = this._elementRef.nativeElement; + } + + ngOnInit() { + this.setClassMap(); + } +} diff --git a/src/components/form/nz-form.module.ts b/src/components/form/nz-form.module.ts new file mode 100644 index 00000000000..39693e10c5e --- /dev/null +++ b/src/components/form/nz-form.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; + +import { NzFormComponent } from './nz-form.component'; +import { NzFormItemDirective } from './nz-form-item.directive'; +import { NzFormControlComponent } from './nz-form-control.component'; +import { NzFormExplainComponent } from './nz-form-explain.directive'; +import { NzFormTextDirective } from './nz-form-text.directive'; +import { NzFormSplitDirective } from './nz-form-split.directive'; +import { NzFormExtraDirective } from './nz-form-extra.directive'; +import { NzFormLabelDirective } from './nz-form-label.directive'; +import { NzFormItemRequiredDirective } from './nz-form-item-required.directive'; +import { CommonModule } from '@angular/common'; + + +@NgModule({ + declarations: [ NzFormExtraDirective, NzFormLabelDirective, NzFormComponent, NzFormItemDirective, NzFormControlComponent, NzFormExplainComponent, NzFormTextDirective, NzFormSplitDirective, NzFormItemRequiredDirective ], + exports : [ NzFormExtraDirective, NzFormLabelDirective, NzFormComponent, NzFormItemDirective, NzFormControlComponent, NzFormExplainComponent, NzFormTextDirective, NzFormSplitDirective, NzFormItemRequiredDirective ], + imports : [ CommonModule ] +}) + +export class NzFormModule { +} diff --git a/src/components/form/style/index.less b/src/components/form/style/index.less new file mode 100755 index 00000000000..18036a39f89 --- /dev/null +++ b/src/components/form/style/index.less @@ -0,0 +1,544 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; +@import "../../input/style/mixin"; +@import "../../button/style/mixin"; +@import "../../grid/style/mixin"; +@import "./mixin"; + +@form-prefix-cls: ~"@{ant-prefix}-form"; +@form-component-height: @input-height-lg; + +.reset-form(); + +label { + position: relative; + + > .@{iconfont-css-prefix} { + vertical-align: top; + font-size: @font-size-base; + } +} + +.@{form-prefix-cls}-item-required:before { + display: inline-block; + margin-right: 4px; + content: "*"; + font-family: SimSun; + line-height: 1; + font-size: @font-size-base; + color: @label-required-color; + .@{form-prefix-cls}-hide-required-mark & { + display: none; + } +} + +// Radio && Checkbox +input[type="radio"], +input[type="checkbox"] { + &[disabled], + &.disabled { + cursor: not-allowed; + } +} + +// These classes are used directly on