diff --git a/docs/assets/data/news.json b/docs/assets/data/news.json new file mode 100644 index 0000000000..919b28de08 --- /dev/null +++ b/docs/assets/data/news.json @@ -0,0 +1,352 @@ +[ + { + "title": "Fan of Angular-In-Depth and my writings? Support us on Twitter!", + "link": "https://blog.angularindepth.com/fan-of-angular-in-depth-and-my-writings-support-us-on-twitter-e3bfcbabb4b1", + "text": "A few weeks ago I ran a poll on Twitter to understand why Angular account has 280k followers on Twitter while Angular-In-Depth has only a fraction of that on Medium (11k). The poll showed that 50% of those who responded don’t use Medium, 17% find stories too complicated, 27% have no time to read and there are people (7%) who find stories not interesting." + }, + { + "title": "Boosting performance of Angular applications with manual change detection", + "link": "https://blog.angularindepth.com/boosting-performance-of-angular-applications-with-manual-change-detection-42cb396110fb", + "text": "Angular uses NgZone/Zone.js to know when to trigger UI update (change detection) when our app data state changes. It brilliantly utilized the events emitted by Zone.js when async operations are performed to detect when to run a change detection cycle." + }, + { + "title": "Learn how Angular Elements transmits Component’s @Outputs outside Angular", + "link": "https://blog.angularindepth.com/how-angular-elements-uses-custom-events-mechanism-to-transmit-components-outputs-outside-angular-7b469386f6e2", + "text": "In our last article we described how Angular Elements works under the hood. We identified that Angular Elements is a bridge to connect Custom Elements to Angular Components." + }, + { + "title": "Angular CDK Tables", + "link": "https://blog.angularindepth.com/angular-cdk-tables-1537774d7c99", + "text": "In this article: Angular CDK Tables, Bootstrap 4 with Angular CDK Tables, Client Side searching/paging/sorting." + }, + { + "title": "One-way template expression binding mechanism in Angular", + "link": "https://blog.angularindepth.com/becoming-an-angular-environmentalist-45a48f7c20d8", + "text": "Angular is the most popular and widely used JavaScript framework after React.js. It abstracts many complexities away from developers to enable them to develop apps with ease." + }, + { + "title": "The Extensive Guide to Creating Streams in RxJS", + "link": "https://blog.angularindepth.com/how-to-unit-test-angular-components-with-fake-ngrx-teststore-f0500cc5fc26", + "text": "For most developers the first contact with RxJS is established by libraries, like Angular. Some functions return streams and to make use of them the focus naturally is on operators." + }, + { + "title": "RxJS: Avoiding Unbound Methods", + "link": "https://blog.angularindepth.com/rxjs-avoiding-unbound-methods-fcf2648a805", + "text": "When unbound methods are passed to RxJS, they will be invoked with an unexpected context for this. If the method implementations don’t use this, they will behave as you would expect." + }, + { + "title": "Angular Elements: how does this magic work under the hood?", + "link": "https://blog.angularindepth.com/angular-elements-how-does-this-magic-work-under-the-hood-3684a0b2be95", + "text": "The Angular Elements project is generating lots of hype in the community right now, and rightly so! Angular Elements provides a wealth of awesome features out of the box." + }, + { + "title": "RxJS: Testing with Fake Time", + "link": "https://blog.angularindepth.com/rxjs-testing-with-fake-time-94114271eed2", + "text": "Angular, Jasmine, Jest and Sinon.JS all provide APIs for running tests with fake time. Their APIs differ, but they are broadly similar. Running tests with fake time avoids having to wait for actual time to elapse and it also makes the tests much simpler, as they run synchronously. So what does this have to do with RxJS?" + }, + { + "title": "How do CDK Portals work?", + "link": "https://blog.angularindepth.com/how-do-cdk-portals-work-7c097c14a494", + "text": "In the last article we were exploring how to leverage the Angular Material CDK portals for placing some piece of template from a component to some other location within our app. CDK portals make this a no-brainer. Wondering how they work? In this article we dive deeper to uncover how its internals work and how we could simply implement it by ourselves." + }, + { + "title": "How I test my NgRx selectors", + "link": "https://blog.angularindepth.com/how-i-test-my-ngrx-selectors-c50b1dc556bc", + "text": "In this post I’m going to show you how I test my selectors by putting the selectors from a previous post “Clean NgRx reducers using Immer”, where we created a small shopping cart application, under test. In the application there is a collection of products (the catalog) and the cart items, together they form the state of the application." + }, + { + "title": "Angular 5 or Angular 6? Yes please!", + "link": "https://blog.angularindepth.com/angular-5-or-angular-6-yes-please-d71b08b5e59b", + "text": "And, I’m glad you asked: YES, you should move all your projects to Angular 6 now or sooner! But … and it is a big but. Like me, you may be in the situation where you are working on multiple projects and many of them are going to be stuck in Angular 5 for a while. So, you need to support a development environment where you can work on and even create new Angular applications in both Angular 5 and Angular 6." + }, + { + "title": "Total Guide To Dynamic Angular Animations That Can Be Customized At Runtime", + "link": "https://blog.angularindepth.com/total-guide-to-dynamic-angular-animations-that-can-be-toggled-at-runtime-be5bb6778a0a", + "text": "From route transitions to small details like feedback when clicking on a button or displaying a tooltip, animations give your project that nice sleek look. Well crafted animations communicate that you or your organization care enough to put effort into details and create best possible experience for your users." + }, + { + "title": "RxJS: How to Observe an Object", + "link": "https://blog.angularindepth.com/rxjs-how-to-observe-an-object-20c47cf51571", + "text": "A while ago, John Lindquist published a package named rx-handler. With it, you can create event handler functions that are also observables. When it was published, I noticed a few queries about whether something similar could be done with Angular’s Input properties — so that they, too, could be treated as observables." + }, + { + "title": "A curious case of the @Host decorator and Element Injectors in Angular", + "link": "https://blog.angularindepth.com/a-curios-case-of-the-host-decorator-and-element-injectors-in-angular-582562abcf0a", + "text": "As you know, Angular’s dependency injection mechanism includes a bunch of decorators like @Optional and @Self which impact the way dependencies are resolved. And while most of them are pretty straightforward and self-explanatory, the @Host decorator has puzzled me for a long time." + }, + { + "title": "Simple state mutations in NGXS with Immer", + "link": "https://blog.angularindepth.com/simple-state-mutations-in-ngxs-with-immer-48b908874a5e", + "text": "NGXS is a state management pattern + library for Angular. Just like Redux and NgRx it’s modeled after the CQRS pattern. NGXS uses TypeScript functionality to its fullest extent and because of this it may feel more Angular-y." + }, + { + "title": "Upgrading a project without CLI to Angular 6", + "link": "https://blog.angularindepth.com/upgrading-a-project-without-cli-to-angular-6-b07b105adc02", + "text": "In the following article, I’m going to describe the challenging process of updating an Angular application with custom Webpack configuration, which our team had to pull through 3 weeks ago. I guess our experience would be useful for those who use Angular with acustom Webpack config. For others, it is an illustration of where modern front-end could lead us and how to live with that." + }, + { + "title": "Power of RxJS when using exponential backoff", + "link": "https://blog.angularindepth.com/power-of-rxjs-when-using-exponential-backoff-a4b8bde276b0", + "text": "Most of the modern-day Angular web apps make Ajax requests to the servers. These requests involve multiple network components (such as routers, switches, etc) as well as servers’ state and everything has to go just right for them to succeed. However, sometimes it doesn’t." + }, + { + "title": "Clean NgRx reducers using Immer", + "link": "https://blog.angularindepth.com/clean-ngrx-reducers-using-immer-7fe4a0d43508", + "text": "This weeks post is inspired by another great This Dot Media event and the topic this time was state management. There was a small segment about Immer which I found interesting (video is linked at the bottom of this post), so I decided to give it a shot with NgRx." + }, + { + "title": "The Angular Library Series - Creating a Library with the Angular CLI", + "link": "https://blog.angularindepth.com/creating-a-library-in-angular-6-87799552e7e5", + "text": "Angular 6 was just released. Many of the improvements were to the Angular CLI. The one I have really been looking forward to is the integration of the Angular CLI with ng-packagr to generate and build Angular libraries. ng-packagr is a fantastic tool created by David Herges that transpiles your library to the Angular Package Format." + }, + { + "title": "RxJS: Avoiding takeUntil Leaks", + "link": "https://blog.angularindepth.com/rxjs-avoiding-takeuntil-leaks-fb5182d047ef", + "text": "Using the takeUntil operator to automatically unsubscribe from an observable is a mechanism that’s explained in Ben Lesh’s Don’t Unsubscribe article. It’s also the basis of a generally-accepted pattern for unsubscribing upon an Angular component’s destruction." + }, + { + "title": "Use ", + "link": "https://blog.angularindepth.com/use-ng-template-c72852c37fba", + "text": "Render Props have been making waves in the React community recently, but the corresponding pattern in the Angular world hasn’t been getting nearly as much press. I’ve written before that TemplateRefs are Angular’s Render Props and I hope to give you a good simple example of that here." + }, + { + "title": "RxJS: Improving the Static pipe Function", + "link": "https://blog.angularindepth.com/rxjs-improving-the-static-pipe-function-81146fbb14b6", + "text": "My previous article looked at using the static pipe function to compose reusable combinations of operators. Most of the time, the pipe function’s TypeScript overload signatures will infer the desired type for the returned function. However, sometimes it’s desirable to have a generic type inferred and the current overload signatures will not do that." + }, + { + "title": "Angular Ivy change detection execution: are you prepared?", + "link": "https://blog.angularindepth.com/angular-ivy-change-detection-execution-are-you-prepared-ab68d4231f2c", + "text": "While new Ivy renderer is not feature completely yet, many people wonder how it will work and what changes it prepares for us. In this article I am going to visualize Ivy change detection mechanism, show some things I am really excited about and also build simple app based on instructions, similar to angular Ivy instructions, from scratch." + }, + { + "title": "Ivy engine in Angular: first in-depth look at compilation, runtime and change detection", + "link": "https://blog.angularindepth.com/ivy-engine-in-angular-first-in-depth-look-at-compilation-runtime-and-change-detection-876751edd9fd", + "text": "I usually finish my talks with the philosophical phrase that nothing stays the same. And as you probably know it’s more then true with Angular. The current rendering engine is being rewritten with the new much enhanced version called Ivy. The current status of Ivy can be tracked here." + }, + { + "title": "RxJS: Combining Operators", + "link": "https://blog.angularindepth.com/rxjs-combining-operators-397bad0628d0", + "text": "In version 5.5, pipeable operators were added to RxJS. And in version 6, their non-pipeable namesakes were removed. Pipeable operators have numerous advantages. The most obvious is that they are easier to write. A less obvious advantage is that they can be composed into reusable combinations." + }, + { + "title": "A modern solution to lazy loading images using Intersection Observer", + "link": "https://blog.angularindepth.com/a-modern-solution-to-lazy-loading-using-intersection-observer-9280c149bbc", + "text": "Performance of a web application has become a key factor in deciding conversion rates for e-commerce websites. The faster a page loads, the better the conversion rate. According to the recent mobile page speed benchmarks released by Google, the bounce probability increases as page load time increases." + }, + { + "title": "Working with DOM in Angular: unexpected consequences and optimization techniques", + "link": "https://blog.angularindepth.com/working-with-dom-in-angular-unexpected-consequences-and-optimization-techniques-682ac09f6866", + "text": "I recently gave a talk on advanced DOM manipulations in Angular in a form of a workshop at NgConf. I went from the basics like using template references and DOM queries to access DOM elements to using a view container to render templates and components dynamically." + }, + { + "title": "The benefits of application state normalization in Angular", + "link": "https://blog.angularindepth.com/the-benefits-of-application-state-normalization-in-angular-f93392ca9f44", + "text": "Imagine we have a recursive data structure in the store, let us say, information about a product’s category in an e-commerce application. Category is the classification of which type of product it is. For example, Mobile Phones category can have subcategories such as Google, Apple, Samsung and so on and each subcategory can in turn have further subcategories..." + }, + { + "title": "RxJS: TSLint Rules for Version 6", + "link": "https://blog.angularindepth.com/rxjs-tslint-rules-for-version-6-d10e2482292d", + "text": "Earlier this week, RxJS version 6 was released and, with its release, managing RxJS imports has become much, much easier. Last year, I wrote a bunch of TSLint rules for managing RxJS imports. They’re distributed in the rxjs-tslint-rules package." + }, + { + "title": "Angular Universal & Firebase functions: The missing guide", + "link": "https://blog.angularindepth.com/angular-5-universal-firebase-4c85a7d00862", + "text": "Lucky you, I’ve written this simplified guide to configure Angular 5 Universal in your Angular project. Moreover, I’m gonna give you also a bonus track on how to run Universal in a serverless environment like Firebase Cloud Functions." + }, + { + "title": "Angular and Internet Explorer", + "link": "https://blog.angularindepth.com/angular-and-internet-explorer-5e59bb6fb4e9", + "text": "You installed the Angular CLI and used it to generate your new application. But, when you try to view it in Internet Explorer (IE), you see nothing. Now what? The bad news: Angular CLI applications require a few more steps in order to support Internet Explorer." + }, + { + "title": "Gestures in an Angular Application", + "link": "https://blog.angularindepth.com/gestures-in-an-angular-application-dde71804c0d0", + "text": "In this post I will attempt to explain how to use hammerjs gesture recognizers provided by the @angular/platform-browser package. I’ll be referencing @angular/platform-browser@5.2.0 within my code samples, but there are some changes coming to 6.0.0 that will be discussed later." + }, + { + "title": "Deploy an Angular Application to IIS", + "link": "https://blog.angularindepth.com/deploy-an-angular-application-to-iis-60a0897742e7", + "text": "The Angular Router is a fantastic module for Single Page Apps. However, to deploy it in a Production scenario you will typically need to do some configuration to make it work. This article details the steps necessary to deploy an Angular Router application anywhere on Internet Information Services (IIS)." + }, + { + "title": "Super Charging an Angular CLI App", + "link": "https://blog.angularindepth.com/super-charging-an-angular-cli-app-fc496a6c100", + "text": "A standard Angular CLI application comes with a terrific set of of tooling to prepare you to get developing quickly. However, there’s a few additional steps you should take to really prepare your project for success. In this article I’ll break down all the additional features you can add to your project without ejecting (exporting the WebPack Config)." + }, + { + "title": "What you always wanted to know about Angular Dependency Injection tree", + "link": "https://blog.angularindepth.com/angular-dependency-injection-and-tree-shakeable-tokens-4588a8f70d5d", + "text": "If you didn’t dive deep into angular dependency injection mechanism, your mental model should be that in angular application we have some root injector with all merged providers, every component has its own injector and lazy loaded module introduces new injector." + }, + { + "title": "RxJS: When to Use switchMap", + "link": "https://blog.angularindepth.com/when-to-use-switchmap-dfe84ac5a1ff", + "text": "In a response to RxJS: Avoiding switchMap-Related Bugs, Martin Hochel mentioned a classic use case for switchMap. For the use case to which he referred, switchMap is not only valid; it’s optimal. And it’s worth looking at why." + }, + { + "title": "RxJS: Understanding Expand", + "link": "https://blog.angularindepth.com/rxjs-understanding-expand-a5f8b41a3602", + "text": "RxJS has a lot of operators. Lots and lots of them. It takes time to learn what they all do and how they can be used. Some operators are straightforward; others, less so. One operator that developers often find confusing is expand." + }, + { + "title": "RxJS: Composing Subscriptions", + "link": "https://blog.angularindepth.com/rxjs-composing-subscriptions-b53ab22f1fd5", + "text": "RxJS code involves making subscriptions to observables. Lots of subscriptions. If each subscription is assigned to its own variable or property, the situation can be difficult to manage." + }, + { + "title": "Handle Template Reference Variables with Directives", + "link": "https://blog.angularindepth.com/handle-template-reference-variables-with-directives-223081bc70c2", + "text": "I’ve been using template reference variables pretty liberally in my examples so far, and it’s high time I dive in a bit into how to use them to reference specific directives." + }, + { + "title": "Avoid Namespace Clashes with Directives", + "link": "https://blog.angularindepth.com/avoid-namespace-clashes-with-directives-1f00d62de445", + "text": "Not only can the selector for a directive clash with another directive, but Inputs and Outputs for those directives can clash with each other. When they have the same name, Angular doesn’t complain — it just applies the logic to both directives. In some cases, this is exactly what we want. However, sometimes it can cause unexpected behavior." + }, + { + "title": "Dynamically Loading Components with Angular CLI", + "link": "https://blog.angularindepth.com/dynamically-loading-components-with-angular-cli-92a3c69bcd28", + "text": "When moving from a multi-page application to a SPA, one of the problems that presents itself is the payload size upon initial load. By default, in an Angular application everything is bundled into one payload, which means as the application grows, so does the time that it takes to load." + }, + { + "title": "Insider’s guide into interceptors and HttpClient mechanics in Angular", + "link": "https://blog.angularindepth.com/insiders-guide-into-interceptors-and-httpclient-mechanics-in-angular-103fbdb397bf", + "text": "You probably know that Angular introduced a new powerful HTTP client in version 4.3. One of its major features was request interception — the ability to declare interceptors which sit in between your application and the backend." + }, + { + "title": "Enhance Components with Directives", + "link": "https://blog.angularindepth.com/enhance-components-with-directives-58f16c4ca1f", + "text": "One element of part 4 of Kent C. Dodds’ series that I didn’t touch on in the previous article is the fact that the withToggle higher order component is able to pull common logic out of the , , and components. There wasn’t very much logic happening in those components in the last article, but what if there were?" + }, + { + "title": "Communicate Between Components Using Dependency Injection", + "link": "https://blog.angularindepth.com/communicate-between-components-using-dependency-injection-d7280567faa7", + "text": "There is another problem we’ve found with our component. We can’t have more than one or component in the same and a that is inside of another custom component won’t be picked up by the @ContentChild decorator." + }, + { + "title": "Build a Toggle Component", + "link": "https://blog.angularindepth.com/build-a-toggle-component-6e8f44889c2c", + "text": "Just like in Kent C. Dodds’ Advanced React Component Patterns, we will use a relatively simple component to illustrate these patterns. The component is responsible for managing a singleboolean property: on." + }, + { + "title": "Introducing Advanced Angular Component Patterns", + "link": "https://blog.angularindepth.com/introducing-advanced-angular-component-patterns-13e102e6bbfc", + "text": "This series of posts is my small attempt to broaden my own view by providing a translation of Kent C. Dodds’ Advanced React Patterns in Angular. My goal is to foster learning and sharing rather than criticism." + }, + { + "title": "Top 10 Angular articles in 2017 from Angular-In-Depth you really want to read", + "link": "https://blog.angularindepth.com/top-10-angular-articles-in-2017-from-angularindepth-you-really-want-to-read-153ae6e497d4", + "text": "Almost one year ago I started Angular-In-Depth medium publication with the goal to become the largest and most technical Angular publication on medium. I was lucky to get on board very talented and knowledgeable guys Uri Shaked, Nicholas Jamieson and Chaz Gatian." + }, + { + "title": "Practical RxJS In The Wild 🦁— Requests with concatMap() vs mergeMap() vs forkJoin() 🥊", + "link": "https://blog.angularindepth.com/practical-rxjs-in-the-wild-requests-with-concatmap-vs-mergemap-vs-forkjoin-11e5b2efe293", + "text": "I would like to share with you experience acquired by working on a yet another Hacker News client (code name HAKAFAKA 😂 still in alpha). I have been on the road for couple months now and realized that a small coding project wouldn’t hurt." + }, + { + "title": "He who thinks change detection is depth-first and he who thinks it’s breadth-first are both usually right", + "link": "https://blog.angularindepth.com/he-who-thinks-change-detection-is-depth-first-and-he-who-thinks-its-breadth-first-are-both-usually-8b6bf24a63e6", + "text": "I was once asked if change detection in Angular is depth or breadth first. This basically means whether Angular first checks siblings of the current component (breadth-first) or its children (depth-first). I hadn’t given any prior thought to this question so I just went with my gut and the knowledge of internals." + }, + { + "title": "Learn to combine RxJs sequences with super intuitive interactive diagrams", + "link": "https://blog.angularindepth.com/learn-to-combine-rxjs-sequences-with-super-intuitive-interactive-diagrams-20fce8e6511", + "text": "When working on a sufficiently complex application you usually have data coming from more than one data source. It can be some multiple external data points like Firebase or several UI widgets interacting with a user. Sequence composition is a technique that enables you to create complex queries." + }, + { + "title": "React Call Return in Angular", + "link": "https://blog.angularindepth.com/react-call-return-in-angular-32a1c9751d6", + "text": "This article continues in the theme of taking React articles and reimagining them in Angular. See TemplateRefs are Angular’s Render Props and Content Directives Are Angular’s Prop Getters." + }, + { + "title": "Do you really know what unidirectional data flow means in Angular", + "link": "https://blog.angularindepth.com/do-you-really-know-what-unidirectional-data-flow-means-in-angular-a6f55cefdc63", + "text": "Most architectural patterns are not easy to grasp especially when the information that describes them is scarce. One of such patterns in Angular is unidirectional data flow. There’s no clear explanation of what that means in the official documentation and it’s only briefly mentioned in the expression guidelines and template statements sections." + }, + { + "title": "How to Reduce Action Boilerplate", + "link": "https://blog.angularindepth.com/how-to-reduce-action-boilerplate-90dc3d389e2b", + "text": "I use Redux for my application development and, to take advantage of RxJS, I use NgRx in Angular projects and redux-observable in React projects. I also use TypeScript." + }, + { + "title": "These 5 articles will make you an Angular Change Detection expert", + "link": "https://blog.angularindepth.com/these-5-articles-will-make-you-an-angular-change-detection-expert-ed530d28930", + "text": "In the last 8 months I’ve spent most of my free time reverse-engineering Angular. The topic that fascinated me the most was change detection. I’d argue that it’s the most important part of the framework since it’s responsible for the “visible” job like DOM updates, input bindings and query list updates." + }, + { + "title": "Angular CDK Portals", + "link": "https://blog.angularindepth.com/angular-cdk-portals-b02f66dd020c", + "text": "The @angular/cdk contains a concept called portals. In this post I’ll attempt to explain the concepts of a Portal, and when they should be applied. The example code in this post is referencing @angular/cdk@2.0.0-beta.12." + }, + { + "title": "Content Directives Are Angular’s Prop Getters", + "link": "https://blog.angularindepth.com/content-directives-are-angulars-prop-getters-360fdae60576", + "text": "Kent C. Dodds wrote a piece about using prop getters in React. Along with render props (see TemplateRefs Are Angular’s Render Props), prop getters allow component library authors to give users as much control of the rendering as possible — the component only needs to do its job." + }, + { + "title": "Using TransferState API in an Angular v5 Universal App", + "link": "https://blog.angularindepth.com/using-transferstate-api-in-an-angular-5-universal-app-130f3ada9e5b", + "text": "You can get a more up-to-date version at https://leanpub.com/angular-universal. Let’s illustrate this article with a concrete example. We have a weather app, displaying a list of cities in its sidebar. When you click on a city name, the app displays the current weather in this city." + }, + { + "title": "Do you still think that NgZone (zone.js) is required for change detection in Angular?", + "link": "https://blog.angularindepth.com/do-you-still-think-that-ngzone-zone-js-is-required-for-change-detection-in-angular-16f7a575afef", + "text": "Most articles I have seen strongly associate Zone(zone.js) and NgZone with change detection in Angular. And although they are definitely related, technically they are not part of one whole. Yes, Zone and NgZone is used to automatically trigger change detection as a result of async operations." + }, + { + "title": "As busy as a bee — lazy loading in the Angular CLI", + "link": "https://blog.angularindepth.com/as-busy-as-a-bee-lazy-loading-in-the-angular-cli-d2812141637f", + "text": "Angular has a programmatic API for lazy loading NgModule’s. In the Angular CLI, it has a direct dependency upon webpack’s underlying toolchain for chunk splitting and lazy loading. It’s thus (almost) impossible to use it outside of an ordinary router set-up. Custom lazy loading strategies need to use SystemJS." + }, + { + "title": "TemplateRefs are Angular’s Render Props", + "link": "https://blog.angularindepth.com/templaterefs-are-angulars-render-props-a2b97cbcc362", + "text": "As a developer that spends most of my time building Angular apps, I still love reading about what the React community is doing. We’re generally solving the same problems and innovation in one community can be leveraged in another." + }, + { + "title": "RxJS: How to Use Lettable Operators with Promises", + "link": "https://blog.angularindepth.com/rxjs-how-to-use-lettable-operators-and-promises-2e717313bf76", + "text": "Converting observables to promises is an antipattern. Unless you are integrating observables with a promise-based API, there is no reason to convert an observable into a promise." + }, + { + "title": "RxJS: Pipelining Lettable Operators", + "link": "https://blog.angularindepth.com/rxjs-pipelining-lettable-operators-f92f6843d817", + "text": "Earlier this week, a TC39 proposal for a pipeline operator moved to stage-1. If the proposal is eventually accepted and included in the ECMAScript standard — it has a long way to go — it will offer a new syntax for lettable operators." + }, + { + "title": "I reverse-engineered Zones (zone.js) and here is what I’ve found", + "link": "https://blog.angularindepth.com/i-reverse-engineered-zones-zone-js-and-here-is-what-ive-found-1f48dc87659b", + "text": "Zones is a new mechanism that helps developers work with multiple logically-connected async operations. Zones work by associating each async operation with a zone." + }, + { + "title": "RxJS: Understanding Lettable Operators", + "link": "https://blog.angularindepth.com/rxjs-understanding-lettable-operators-fe74dda186d3", + "text": "Lettable operators offer a new way of composing observable chains and they have advantages for both application developers and library authors. Let’s look briefly at the existing composition mechanisms in RxJS and then look at lettable operators in more detail." + }, + { + "title": "The essential difference between Constructor and ngOnInit in Angular", + "link": "https://blog.angularindepth.com/the-essential-difference-between-constructor-and-ngoninit-in-angular-c9930c209a42", + "text": "One of the most popular Angular questions on stackoverflow is Difference between Constructor and ngOnInit with over 100k views. I gave my answer to this question there but also decided to expand on it in this article." + }, + { + "title": "RxJS: How to Use refCount", + "link": "https://blog.angularindepth.com/rxjs-how-to-use-refcount-73a0c6619a4e", + "text": "My previous article — Understanding the publish and share Operators — looked only briefly at the refCount method. Let’s look at it more closely here." + }, + { + "title": "The essential difference between pure and impure pipes in Angular and why that matters", + "link": "https://blog.angularindepth.com/the-essential-difference-between-pure-and-impure-pipes-and-why-that-matters-999818aa068", + "text": "When writing a custom pipe in Angular you can specify whether you define a pure or an impure pipe. Angular has a pretty good documentation on pipes that you can find here. But as it often happens with documentation the clearly reasoning for division is missing." + }, + { + "title": "RxJS: Understanding the publish and share Operators", + "link": "https://blog.angularindepth.com/rxjs-understanding-the-publish-and-share-operators-16ea2f446635", + "text": "I’m often asked questions that relate to the publish operator: What’s the difference between publish and share? How do I import the refCount operator? When should I use an AsyncSubject? Let’s answer these questions — and more — by starting with the basics." + }, + { + "title": "If you think `ngDoCheck` means your component is being checked — read this article", + "link": "https://blog.angularindepth.com/if-you-think-ngdocheck-means-your-component-is-being-checked-read-this-article-36ce63a3f3e5", + "text": "There’s one question that comes up again and again on stackoverflow. The question is about ngDoCheck lifecycle hook that is triggered for a component that implements OnPush change detection strategy." + } +] diff --git a/docs/assets/images/components/infinite-scroll.svg b/docs/assets/images/components/infinite-scroll.svg new file mode 100644 index 0000000000..5cd635b59d --- /dev/null +++ b/docs/assets/images/components/infinite-scroll.svg @@ -0,0 +1,46 @@ + + + + 723B61EE-8CCA-4B08-B6F3-0F08A814E117 + Created with sketchtool. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/images/components/list.svg b/docs/assets/images/components/list.svg new file mode 100644 index 0000000000..319a9169c3 --- /dev/null +++ b/docs/assets/images/components/list.svg @@ -0,0 +1,57 @@ + + + + 80874A9B-A6FA-4172-A8E0-A6FCD6FC91D8 + Created with sketchtool. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/structure.ts b/docs/structure.ts index c94b02cb9e..af1e04374a 100644 --- a/docs/structure.ts +++ b/docs/structure.ts @@ -327,11 +327,23 @@ export const structure = [ 'NbAccordionItemBodyComponent', ], }, + { + type: 'tabs', + name: 'List', + icon: 'list.svg', + source: [ 'NbListComponent', 'NbListItemComponent' ], + }, + { + type: 'tabs', + name: 'Infinite List', + icon: 'infinite-scroll.svg', + source: [ 'NbInfiniteListDirective', 'NbListPageTrackerDirective' ], + }, { type: 'tabs', name: 'Input', icon: 'input.svg', - source: ['NbInputDirective'], + source: [ 'NbInputDirective' ], }, ], }, diff --git a/karma.conf.js b/karma.conf.js index 0493000338..47c5180262 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -27,6 +27,9 @@ module.exports = function (config) { environment: 'dev' }, reporters: ['spec', 'kjhtml'], + specReporter: { + suppressSkipped: true, + }, port: 9876, browserNoActivityTimeout : 60000, colors: true, diff --git a/package-lock.json b/package-lock.json index 188e89fef5..4a8f9323f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9667,6 +9667,11 @@ "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", "dev": true }, + "intersection-observer": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.5.0.tgz", + "integrity": "sha512-8Zgt4ijlyvIrQVTA7MPb2W9+KhoetrAbxlh0RmTGxpx0+ZsAXvy7IsbNnZIrqZ6TddAdWeQj49x7Ph7Ir6KRkA==" + }, "into-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", diff --git a/package.json b/package.json index dd722ab498..ed9d51f573 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "docsearch.js": "^2.5.2", "gulp-bump": "2.7.0", "highlight.js": "9.12.0", + "intersection-observer": "0.5.0", "ionicons": "2.0.1", "jasmine-fail-fast": "2.0.0", "leaflet": "1.0.3", diff --git a/scripts/gulp/tasks/bundle/rollup-config.ts b/scripts/gulp/tasks/bundle/rollup-config.ts index 8464b5b387..edcca4e1b1 100644 --- a/scripts/gulp/tasks/bundle/rollup-config.ts +++ b/scripts/gulp/tasks/bundle/rollup-config.ts @@ -24,6 +24,7 @@ const ROLLUP_GLOBALS = { 'rxjs/operators': 'Rx.operators', // 3rd party dependencies + 'intersection-observer': 'intersection-observer', // @nebular dependencies '@nebular/theme': 'nb.theme', diff --git a/src/framework/theme/components/card/card.component.ts b/src/framework/theme/components/card/card.component.ts index 5be9ee7e05..1c113063e5 100644 --- a/src/framework/theme/components/card/card.component.ts +++ b/src/framework/theme/components/card/card.component.ts @@ -7,7 +7,7 @@ import { Component, Input, HostBinding } from '@angular/core'; /** - * Component intended to be used within the `` component. + * Component intended to be used within the `` component. * It adds styles for a preset header section. * * @styles @@ -73,6 +73,14 @@ export class NbCardFooterComponent { * Card with header and footer: * @stacked-example(With Header & Footer, card/card-full.component) * + * Most of the time main card content goes to `nb-card-body`, + * so it is styled and aligned in accordance with the header and footer. + * In case you need a higher level of control, you can pass contend directly to `nb-card`, + * so `nb-card-body` styling will not be applied. + * + * Consider an example with `nb-list` component: + * @stacked-example(Showcase, card/card-without-body.component) + * * Colored cards could be simply configured by providing a `status` property: * @stacked-example(Colored Card, card/card-colors.component) * @@ -106,9 +114,9 @@ export class NbCardFooterComponent { selector: 'nb-card', styleUrls: ['./card.component.scss'], template: ` - + `, }) diff --git a/src/framework/theme/components/input/input.spec.ts b/src/framework/theme/components/input/input.spec.ts index 30cfd9579c..7c373cc584 100644 --- a/src/framework/theme/components/input/input.spec.ts +++ b/src/framework/theme/components/input/input.spec.ts @@ -7,7 +7,7 @@ import { Component, ViewChild, ElementRef, Input } from '@angular/core' import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NbInputDirective } from './input.directive'; -import { NbInputModule } from '@nebular/theme'; +import { NbInputModule } from './input.module'; @Component({ template: ` diff --git a/src/framework/theme/components/list/_list.component.theme.scss b/src/framework/theme/components/list/_list.component.theme.scss new file mode 100644 index 0000000000..a11f908489 --- /dev/null +++ b/src/framework/theme/components/list/_list.component.theme.scss @@ -0,0 +1,10 @@ +@mixin nb-list-theme() { + nb-list-item { + border-bottom: 1px solid nb-theme(list-item-border-color); + padding: nb-theme(list-item-padding); + + &:first-child { + border-top: 1px solid nb-theme(list-item-border-color); + } + } +} diff --git a/src/framework/theme/components/list/infinite-list.directive.spec.ts b/src/framework/theme/components/list/infinite-list.directive.spec.ts new file mode 100644 index 0000000000..3126359458 --- /dev/null +++ b/src/framework/theme/components/list/infinite-list.directive.spec.ts @@ -0,0 +1,276 @@ +import { Component, ViewChild, ElementRef } from '@angular/core'; +import { APP_BASE_HREF } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; +import { NbThemeModule } from '../../theme.module'; +import { NbLayoutModule } from '../layout/layout.module'; +import { NbLayoutComponent } from '../layout/layout.component'; +import { NbLayoutScrollService } from '../../services/scroll.service'; +import { NbListModule } from './list.module'; +import { NbListComponent } from './list.component'; +import { NbInfiniteListDirective } from './infinite-list.directive'; + +const CONTENT_PADDING = 20; +const CONTENT_HEIGHT = 10000 + CONTENT_PADDING; +const ELEMENT_HEIGHT = 500; +const THRESHOLD = 200; + +let fixture: ComponentFixture; +let testComponent: ScrollTestComponent; +let scrollingElementRef: ElementRef; +let scrollDirective: NbInfiniteListDirective; + +@Component({ + template: ` + + + + + + + + `, + styles: [` + ::ng-deep nb-layout.with-scroll .scrollable-container { + overflow: auto; + height: 100vh; + } + .scroller { + background: lightgray; + padding: ${CONTENT_PADDING}px; + } + .element-scroll { + height: ${ELEMENT_HEIGHT}px; + overflow-y: auto; + } + .inner { + background: lightgoldenrodyellow; + height: ${CONTENT_HEIGHT}px; + } + `], +}) +class ScrollTestComponent { + @ViewChild(NbListComponent, { read: ElementRef }) listElementRef: ElementRef; + @ViewChild(NbInfiniteListDirective) infiniteListDirective: NbInfiniteListDirective; + @ViewChild(NbLayoutComponent) layoutComponent: NbLayoutComponent; + + listenWindowScroll = false; + threshold = THRESHOLD; + withScroll = false; + + bottomThreshold() {} + topThreshold() {} +} + +describe('Directive: NbScrollDirective', () => { + + beforeEach(() => { + fixture = TestBed.configureTestingModule({ + imports: [ + RouterModule.forRoot([]), + NbThemeModule.forRoot({ name: 'default' }), + NbLayoutModule, + NbListModule, + ], + providers: [ NbLayoutScrollService, { provide: APP_BASE_HREF, useValue: '/' } ], + declarations: [ ScrollTestComponent ], + }) + .createComponent(ScrollTestComponent); + + testComponent = fixture.componentInstance; + scrollingElementRef = testComponent.listElementRef; + scrollDirective = testComponent.infiniteListDirective; + + fixture.detectChanges(); + }); + + afterEach(fakeAsync(() => { + fixture.destroy(); + tick(); + fixture.nativeElement.remove(); + })); + + it('should listen to window scroll', () => { + const checkPositionSpy = spyOn(scrollDirective, 'checkPosition'); + testComponent.listenWindowScroll = true; + fixture.detectChanges(); + + window.dispatchEvent(new Event('scroll')); + expect(checkPositionSpy).toHaveBeenCalledTimes(1); + }); + + it('should listen to layout scroll', () => { + const checkPositionSpy = spyOn(scrollDirective, 'checkPosition'); + testComponent.listenWindowScroll = true; + testComponent.withScroll = true; + fixture.detectChanges(); + + testComponent.layoutComponent.scrollableContainerRef.nativeElement.dispatchEvent(new Event('scroll')); + + expect(checkPositionSpy).toHaveBeenCalledTimes(1); + }); + + it('should listen to element scroll', () => { + const elementScrollHandlerSpy = spyOn(scrollDirective, 'onElementScroll'); + scrollingElementRef.nativeElement.dispatchEvent(new Event('scroll')); + expect(elementScrollHandlerSpy).toHaveBeenCalledTimes(1); + }); + + it('should ignore window and layout scroll when listening to element scroll', () => { + const checkPositionSpy = spyOn(scrollDirective, 'checkPosition'); + + window.dispatchEvent(new Event('scroll')); + expect(checkPositionSpy).toHaveBeenCalledTimes(0); + + const layoutScrollContainer = testComponent.layoutComponent.scrollableContainerRef.nativeElement; + layoutScrollContainer.dispatchEvent(new Event('scroll')); + expect(checkPositionSpy).toHaveBeenCalledTimes(0); + + scrollingElementRef.nativeElement.dispatchEvent(new Event('scroll')); + expect(checkPositionSpy).toHaveBeenCalledTimes(1); + }); + + it('should ignore element scroll when listening to window or layout scroll', () => { + testComponent.listenWindowScroll = true; + fixture.detectChanges(); + + const checkPositionSpy = spyOn(scrollDirective, 'checkPosition'); + + scrollingElementRef.nativeElement.dispatchEvent(new Event('scroll')); + expect(checkPositionSpy).toHaveBeenCalledTimes(0); + + window.dispatchEvent(new Event('scroll')); + expect(checkPositionSpy).toHaveBeenCalledTimes(1); + + testComponent.withScroll = true; + fixture.detectChanges(); + + scrollingElementRef.nativeElement.dispatchEvent(new Event('scroll')); + expect(checkPositionSpy).toHaveBeenCalledTimes(1); + + const layoutScrollContainer = testComponent.layoutComponent.scrollableContainerRef.nativeElement; + layoutScrollContainer.dispatchEvent(new Event('scroll')); + expect(checkPositionSpy).toHaveBeenCalledTimes(2); + }); + + it('should trigger bottomThreshold only when treshold reached (element scroll)', fakeAsync(() => { + const scrollingNativeElement = scrollingElementRef.nativeElement; + const tresholdSpy = spyOn(testComponent, 'bottomThreshold'); + + const positionUnderThreshold = CONTENT_HEIGHT - THRESHOLD - ELEMENT_HEIGHT - 1; + scrollingNativeElement.scrollTop = positionUnderThreshold; + scrollingNativeElement.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(0); + + const positionBelowThreshold = CONTENT_HEIGHT - (THRESHOLD / 2); + scrollingNativeElement.scrollTop = positionBelowThreshold; + scrollingNativeElement.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(1); + })); + + it('should trigger bottomThreshold only when treshold reached (window scroll)', fakeAsync(() => { + const { documentElement } = document; + + testComponent.listenWindowScroll = true; + fixture.detectChanges(); + + const tresholdSpy = spyOn(testComponent, 'bottomThreshold'); + + const reporterHeight = 1000; + const positionUnderThreshold = CONTENT_HEIGHT - THRESHOLD - reporterHeight; + documentElement.scrollTop = positionUnderThreshold; + window.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(0); + + const positionBelowThreshold = CONTENT_HEIGHT - (THRESHOLD / 2); + documentElement.scrollTop = positionBelowThreshold; + window.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(1); + })); + + it('should trigger bottomThreshold only when treshold reached (layout scroll)', fakeAsync(() => { + const scroller: Element = testComponent.layoutComponent.scrollableContainerRef.nativeElement; + + testComponent.listenWindowScroll = true; + testComponent.withScroll = true; + fixture.detectChanges(); + + const tresholdSpy = spyOn(testComponent, 'bottomThreshold'); + + const positionUnderThreshold = CONTENT_HEIGHT - THRESHOLD - scroller.clientHeight - 1; + scroller.scrollTop = positionUnderThreshold; + scroller.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(0); + + const positionBelowThreshold = CONTENT_HEIGHT - THRESHOLD / 2; + scroller.scrollTop = positionBelowThreshold; + scroller.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(1); + })); + + it('should trigger topThreshold when treshold reached (element)', fakeAsync(() => { + const scrollingElement = scrollingElementRef.nativeElement; + const tresholdSpy = spyOn(testComponent, 'topThreshold'); + + scrollingElement.scrollTop = THRESHOLD + 1; + scrollingElement.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(0); + + scrollingElement.scrollTop = THRESHOLD - 1; + scrollingElement.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(1); + })); + + it('should trigger topThreshold when treshold reached (window)', fakeAsync(() => { + testComponent.listenWindowScroll = true; + fixture.detectChanges(); + + const { documentElement } = document; + const tresholdSpy = spyOn(testComponent, 'topThreshold'); + + documentElement.scrollTop = THRESHOLD + 1; + window.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(0); + + documentElement.scrollTop = THRESHOLD - 1; + window.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(1); + })); + + it('should trigger topThreshold when treshold reached (layout scroll)', fakeAsync(() => { + testComponent.listenWindowScroll = true; + testComponent.withScroll = true; + fixture.detectChanges(); + + const layoutElement = testComponent.layoutComponent.scrollableContainerRef.nativeElement; + const tresholdSpy = spyOn(testComponent, 'topThreshold'); + + layoutElement.scrollTop = THRESHOLD + 1; + layoutElement.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(0); + + layoutElement.scrollTop = THRESHOLD - 1; + layoutElement.dispatchEvent(new Event('scroll')); + tick(); + expect(tresholdSpy).toHaveBeenCalledTimes(1); + })); +}); diff --git a/src/framework/theme/components/list/infinite-list.directive.ts b/src/framework/theme/components/list/infinite-list.directive.ts new file mode 100644 index 0000000000..2c4a80776e --- /dev/null +++ b/src/framework/theme/components/list/infinite-list.directive.ts @@ -0,0 +1,175 @@ +import { + Directive, + Input, + HostListener, + ElementRef, + EventEmitter, + Output, + OnDestroy, + AfterViewInit, + ContentChildren, + QueryList, +} from '@angular/core'; +import { Observable, forkJoin, of as observableOf, interval, timer } from 'rxjs'; +import { takeWhile, filter, switchMap, map, takeUntil, take } from 'rxjs/operators'; +import { convertToBoolProperty } from '../helpers'; +import { NbLayoutScrollService } from '../../services/scroll.service'; +import { NbLayoutRulerService } from '../../services/ruler.service'; +import { NbListItemComponent } from './list.component'; + +export class NbScrollableContainerDimentions { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +} + +/** + * Infinite List Directive + * + * ```html + * + * + * + * ``` + * + * @stacked-example(Simple infinite list, infinite-list/infinite-list-showcase.component) + * + * Directive will notify when list scrolled up or down to given a threshold. + * By default it listen to scroll of list on which applied, but also can be set to listen to window scroll. + * + * @stacked-example(Scroll modes, infinite-list/infinite-list-scroll-modes.component) + * + * To improve UX of infinite lists, it's better to keep current page in url, + * so user able to return to the last viewed page or to share a link to this page. + * `nbListPageTracker` directive will help you to know, what page user currently viewing. + * Just put it on a list, set page size and it will calculate page that currently in viewport. + * You can [open the example](example/infinite-list/infinite-news-list.component) + * in a new tab to check out this feature. + * + * @stacked-example(Infinite list with pager, infinite-list/infinite-news-list.component) + * + */ +@Directive({ + selector: '[nbInfiniteList]', +}) +export class NbInfiniteListDirective implements AfterViewInit, OnDestroy { + + private alive = true; + private lastScrollPosition; + windowScroll = false; + private get elementScroll() { + return !this.windowScroll; + } + + /** + * Threshold after which event load more event will be emited. + * In pixels. + */ + @Input() + threshold: number; + + /** + * By default component observes list scroll position. + * If set to `true`, component will observe position of page scroll instead. + */ + @Input() + set listenWindowScroll(value) { + this.windowScroll = convertToBoolProperty(value); + } + + /** + * Emits when distance between list bottom and current scroll position is less than threshold. + */ + @Output() + bottomThreshold = new EventEmitter(true); + + /** + * Emits when distance between list top and current scroll position is less than threshold. + */ + @Output() + topThreshold = new EventEmitter(true); + + @HostListener('scroll') + onElementScroll() { + if (this.elementScroll) { + this.checkPosition(this.elementRef.nativeElement); + } + } + + @ContentChildren(NbListItemComponent) listItems: QueryList; + + constructor( + private elementRef: ElementRef, + private scrollService: NbLayoutScrollService, + private dimensionsService: NbLayoutRulerService, + ) {} + + ngAfterViewInit() { + this.scrollService.onScroll() + .pipe( + takeWhile(() => this.alive), + filter(() => this.windowScroll), + switchMap(() => this.getContainerDimentions()), + ) + .subscribe(dimentions => this.checkPosition(dimentions)); + + this.listItems.changes + .pipe( + takeWhile(() => this.alive), + // For some reason, changes are emitted before list item removed from dom, + // so dimensions will be incorrect. + // Check every 50ms for a second if dom and query are in sync. + // Once they synchronized, we can get proper dimensions. + switchMap(() => interval(50).pipe( + takeUntil(timer(1000)), + filter(() => this.inSyncWithDom()), + take(1), + )), + switchMap(() => this.getContainerDimentions()), + ) + .subscribe(dimentions => this.checkPosition(dimentions)); + + this.getContainerDimentions().subscribe(dimentions => this.checkPosition(dimentions)); + } + + ngOnDestroy() { + this.alive = false; + } + + checkPosition({ scrollHeight, scrollTop, clientHeight }: NbScrollableContainerDimentions) { + const initialCheck = this.lastScrollPosition == null; + const manualCheck = this.lastScrollPosition === scrollTop; + const scrollUp = scrollTop < this.lastScrollPosition; + const scrollDown = scrollTop > this.lastScrollPosition; + const distanceToBottom = scrollHeight - scrollTop - clientHeight; + + if ((initialCheck || manualCheck || scrollDown) && distanceToBottom <= this.threshold) { + this.bottomThreshold.emit(); + } + if ((initialCheck || scrollUp) && scrollTop <= this.threshold) { + this.topThreshold.emit(); + } + + this.lastScrollPosition = scrollTop; + } + + private getContainerDimentions(): Observable { + if (this.elementScroll) { + const { scrollTop, scrollHeight, clientHeight } = this.elementRef.nativeElement; + return observableOf({ scrollTop, scrollHeight, clientHeight }); + } + + return forkJoin(this.scrollService.getPosition(), this.dimensionsService.getDimensions()) + .pipe( + map(([scrollPosition, dimensions]) => ({ + scrollTop: scrollPosition.y, + scrollHeight: dimensions.scrollHeight, + clientHeight: dimensions.clientHeight, + })), + ); + } + + private inSyncWithDom(): boolean { + return this.elementRef.nativeElement.children.length === this.listItems.length; + } +} diff --git a/src/framework/theme/components/list/list-item.component.scss b/src/framework/theme/components/list/list-item.component.scss new file mode 100644 index 0000000000..dd372eabb7 --- /dev/null +++ b/src/framework/theme/components/list/list-item.component.scss @@ -0,0 +1,3 @@ +:host { + flex-shrink: 0; +} diff --git a/src/framework/theme/components/list/list-page-tracker.directive.ts b/src/framework/theme/components/list/list-page-tracker.directive.ts new file mode 100644 index 0000000000..95fa4285b9 --- /dev/null +++ b/src/framework/theme/components/list/list-page-tracker.directive.ts @@ -0,0 +1,125 @@ +import { + Directive, + ContentChildren, + QueryList, + Input, + ElementRef, + AfterViewInit, + OnDestroy, + Output, + EventEmitter, +} from '@angular/core'; +import { takeWhile } from 'rxjs/operators'; +import 'intersection-observer'; +import { NbListItemComponent } from './list.component'; + +/** + * List pager directive + * + * Directive allows you to determine page of currently viewing items. + * + */ +@Directive({ + selector: '[nbListPageTracker]', +}) +export class NbListPageTrackerDirective implements AfterViewInit, OnDestroy { + + private alive = true; + + private observer: IntersectionObserver; + private currentPage: number; + + /** + * Items per page. + */ + @Input() + pageSize: number; + + /** + * Page to start counting with. + */ + @Input() + startPage: number = 1; + + /** + * Emits when another page become visible. + */ + @Output() + pageChange = new EventEmitter(); + + @ContentChildren(NbListItemComponent, { read: ElementRef }) + listItems: QueryList; + + constructor() { + this.observer = new IntersectionObserver( + entries => this.checkForPageChange(entries), + { threshold: 0.5 }, + ); + } + + ngAfterViewInit() { + if (this.listItems && this.listItems.length) { + this.observeItems(); + } + + this.listItems.changes + .pipe(takeWhile(() => this.alive)) + .subscribe(() => this.observeItems()); + } + + ngOnDestroy() { + this.observer.disconnect && this.observer.disconnect(); + } + + private observeItems() { + this.listItems.forEach(i => this.observer.observe(i.nativeElement)); + } + + private checkForPageChange(entries: IntersectionObserverEntry[]) { + const mostVisiblePage = this.findMostVisiblePage(entries); + + if (mostVisiblePage && this.currentPage !== mostVisiblePage) { + this.currentPage = mostVisiblePage; + this.pageChange.emit(this.currentPage); + } + } + + private findMostVisiblePage(entries: IntersectionObserverEntry[]): number | null { + const intersectionRatioByPage = new Map(); + + for (const entry of entries) { + if (entry.intersectionRatio < 0.5) { + continue; + } + + const elementIndex = this.elementIndex(entry.target); + if (elementIndex === -1) { + continue; + } + const page = this.startPage + Math.floor(elementIndex / this.pageSize); + + let ratio = entry.intersectionRatio; + if (intersectionRatioByPage.has(page)) { + ratio += intersectionRatioByPage.get(page); + } + intersectionRatioByPage.set(page, ratio); + } + + let maxRatio = 0; + let mostVisiblePage; + intersectionRatioByPage.forEach((ratio, page) => { + if (ratio > maxRatio) { + maxRatio = ratio; + mostVisiblePage = page; + } + }); + + return mostVisiblePage; + } + + private elementIndex(element: Element): number { + return element.parentElement && element.parentElement.children + ? Array.from(element.parentElement.children).indexOf(element) + : -1; + } +} diff --git a/src/framework/theme/components/list/list-pager.directive.spec.ts b/src/framework/theme/components/list/list-pager.directive.spec.ts new file mode 100644 index 0000000000..a328524f33 --- /dev/null +++ b/src/framework/theme/components/list/list-pager.directive.spec.ts @@ -0,0 +1,237 @@ +import { Component, ViewChild, ElementRef } from '@angular/core'; +import { TestBed, ComponentFixture, fakeAsync, tick, ComponentFixtureAutoDetect } from '@angular/core/testing'; +import { NbListModule } from './list.module'; +import { NbListComponent } from './list.component'; + +function waitForSpyCall(spy: jasmine.Spy, checkInterval: number = 40, timeout: number = 500): Promise { + const initialCallsCount = spy.calls.count(); + + return new Promise((resolve, reject) => { + let intervalId; + const timeoutId = setTimeout(() => { + clearInterval(intervalId); + reject(); + }, timeout); + + intervalId = setInterval(() => { + if (spy.calls.count() > initialCallsCount) { + clearTimeout(timeoutId); + clearInterval(intervalId); + resolve(); + } + }, checkInterval); + }); +} + +const ITEMS_PER_PAGE: number = 10; +const ITEM_HEIGHT: number = 100; +const LIST_HEIGHT: number = 500; +const PAGE_HEIGHT: number = ITEMS_PER_PAGE * ITEM_HEIGHT; +let initialItemsCount: number = 100; + +@Component({ + template: ` + + + + `, + styles: [` + .list { + background: lightslategray; + height: ${LIST_HEIGHT}px; + padding: 0 5px; + overflow: auto; + } + .list-item { + background: lightblue; + border: ${ITEM_HEIGHT * 0.01}px solid black; + height: ${ITEM_HEIGHT * 0.98}px; + } + `], +}) +class PagerTestComponent { + @ViewChild(NbListComponent, { read: ElementRef }) listElementRef: ElementRef; + + get listElement(): Element { + return this.listElementRef.nativeElement; + } + + items = new Array(initialItemsCount); + pageSize = ITEMS_PER_PAGE; + startPage = 1; + + pageChanged() {} +} + +let fixture: ComponentFixture; +let testComponent: PagerTestComponent; +let pageChangedSpy: jasmine.Spy; + +describe('Directive: NbListPageTrackerDirective', () => { + + let initialTimeoutInterval; + beforeAll(() => { + initialTimeoutInterval = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000; + }); + afterAll(() => jasmine.DEFAULT_TIMEOUT_INTERVAL = initialTimeoutInterval); + + beforeEach(() => { + fixture = TestBed.configureTestingModule({ + imports: [ NbListModule ], + declarations: [ PagerTestComponent ], + providers: [ { provide: ComponentFixtureAutoDetect, useValue: true } ], + }) + .createComponent(PagerTestComponent); + + testComponent = fixture.componentInstance; + fixture.detectChanges(); + + pageChangedSpy = spyOn(testComponent, 'pageChanged'); + }); + + afterEach(fakeAsync(() => { + fixture.destroy(); + tick(); + fixture.nativeElement.remove(); + })); + + describe('initial page', () => { + + it('should emit initial page change when list was prefilled', async () => { + try { + await waitForSpyCall(pageChangedSpy); + } catch { + fail('Page change should be emmited'); + } + + expect(pageChangedSpy).toHaveBeenCalledTimes(1); + expect(pageChangedSpy).toHaveBeenCalledWith(1); + }); + + describe('empty list', () => { + + let initialItemsCountBefore; + beforeAll(() => { + initialItemsCountBefore = initialItemsCount; + initialItemsCount = 0; + }); + afterAll(() => initialItemsCount = initialItemsCountBefore); + + it('should not emit initial page change when list is empty', async () => { + try { + await waitForSpyCall(pageChangedSpy); + fail('Page change should not be emmited'); + } catch {} + + expect(pageChangedSpy).not.toHaveBeenCalled(); + }); + + it(`should emit initial page change when items added to empty list`, async () => { + testComponent.items = new Array(initialItemsCountBefore); + fixture.detectChanges(); + try { + await waitForSpyCall(pageChangedSpy); + } catch { + fail('Page change should be emmited'); + } + + expect(pageChangedSpy).toHaveBeenCalledTimes(1); + expect(pageChangedSpy).toHaveBeenCalledWith(1); + }); + }); + }); + + describe('start page', () => { + + let initialItemsCountBefore; + beforeAll(() => { + initialItemsCountBefore = initialItemsCount; + initialItemsCount = 0; + }); + afterAll(() => initialItemsCount = initialItemsCountBefore); + + it('should take into account start page when calculating current page', async () => { + const startPage = 5; + const { listElement } = testComponent; + + testComponent.items = new Array(initialItemsCountBefore); + testComponent.startPage = startPage; + fixture.detectChanges(); + try { + await waitForSpyCall(pageChangedSpy); + } catch { + fail('pageChanged should be called after adding new items to empty list'); + } + expect(pageChangedSpy).toHaveBeenCalledTimes(1); + expect(pageChangedSpy).toHaveBeenCalledWith(startPage); + + const numberOfPagesToScroll = [ 1, 5 ]; + let timesPageShouldBeChanged = 1; + for (const nPagesToScroll of numberOfPagesToScroll) { + listElement.scrollTop = PAGE_HEIGHT * nPagesToScroll; + try { + await waitForSpyCall(pageChangedSpy); + timesPageShouldBeChanged++; + } catch { + fail(`pageChanged should be called after scrolling ${startPage + nPagesToScroll} pages down`); + } + + expect(pageChangedSpy).toHaveBeenCalledTimes(timesPageShouldBeChanged); + expect(pageChangedSpy).toHaveBeenCalledWith(startPage + nPagesToScroll); + } + }); + }); + + describe(`page change`, () => { + + beforeEach(async () => { + try { + await waitForSpyCall(pageChangedSpy); + } catch { + throw new Error('No initial page call'); + } + // 'pageChanged' will be called once after list initialization, since list has items. + // Reset to start counting from zero calls. + pageChangedSpy.calls.reset(); + }); + + it('should not emit page change when scrolling within current page', async () => { + const { listElement } = testComponent; + const positionBeforePageTwo = PAGE_HEIGHT - LIST_HEIGHT; + listElement.scrollTop = positionBeforePageTwo; + try { + await waitForSpyCall(pageChangedSpy); + } catch { /* Expecting to throw because 'pageChanged' shouldn't be called since page wasn't changed. */ } + + expect(pageChangedSpy).not.toHaveBeenCalled(); + }); + + it('should emit page change when scrolling to another pages', async () => { + const { listElement } = testComponent; + + const startPage = 1; + let timesPageShouldBeChanged = 0; + const lastPage = initialItemsCount / ITEMS_PER_PAGE - 1; + const numbersOfPagesToScroll = [ 1, 2, lastPage, 0 ]; + + for (const pagesToScroll of numbersOfPagesToScroll) { + listElement.scrollTop = PAGE_HEIGHT * pagesToScroll; + try { + await waitForSpyCall(pageChangedSpy); + timesPageShouldBeChanged++; + } catch { + fail(`pageChanged should be called after scrolling to ${startPage + pagesToScroll} page`); + } + + expect(pageChangedSpy).toHaveBeenCalledTimes(timesPageShouldBeChanged); + expect(pageChangedSpy).toHaveBeenCalledWith(startPage + pagesToScroll); + } + }); + }); +}); diff --git a/src/framework/theme/components/list/list.component.scss b/src/framework/theme/components/list/list.component.scss new file mode 100644 index 0000000000..399a45e0b1 --- /dev/null +++ b/src/framework/theme/components/list/list.component.scss @@ -0,0 +1,6 @@ +:host { + display: flex; + flex-direction: column; + flex: 1 1 auto; + overflow: auto; +} diff --git a/src/framework/theme/components/list/list.component.ts b/src/framework/theme/components/list/list.component.ts new file mode 100644 index 0000000000..6f70009595 --- /dev/null +++ b/src/framework/theme/components/list/list.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, HostBinding } from '@angular/core'; + +/** + * List is a container component that wraps `nb-list-item` component. + * + * Basic example: + * @stacked-example(Simple list, list/simple-list-showcase.component) + * + * `nb-list-item` accepts arbitrary content, so you can create list of any components. + * + * List of users: + * @stacked-example(Users list, list/users-list-showcase.component) + * + * @styles + * + * list-item-border-color: + * list-item-padding: + */ +@Component({ + selector: 'nb-list', + template: ``, + styleUrls: [ './list.component.scss' ], +}) +export class NbListComponent { + /** + * Role attribute value + * + * @type {string} + */ + @Input() + @HostBinding('attr.role') + role = 'list'; +} + +/** + * List item component is a grouping component that accepts arbitrary content. + * It should be direct child of `nb-list` componet. + */ +@Component({ + selector: 'nb-list-item', + template: ``, + styleUrls: [ 'list-item.component.scss' ], +}) +export class NbListItemComponent { + /** + * Role attribute value + * + * @type {string} + */ + @Input() + @HostBinding('attr.role') + role = 'listitem'; +} diff --git a/src/framework/theme/components/list/list.module.ts b/src/framework/theme/components/list/list.module.ts new file mode 100644 index 0000000000..356d2d0587 --- /dev/null +++ b/src/framework/theme/components/list/list.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { NbListComponent, NbListItemComponent } from './list.component'; +import { NbListPageTrackerDirective } from './list-page-tracker.directive'; +import { NbInfiniteListDirective } from './infinite-list.directive'; + +const components = [ + NbListComponent, + NbListItemComponent, + NbListPageTrackerDirective, + NbInfiniteListDirective, +]; + +@NgModule({ + declarations: components, + exports: components, +}) +export class NbListModule {} diff --git a/src/framework/theme/index.ts b/src/framework/theme/index.ts index fda65c9ab0..4d9c10e273 100644 --- a/src/framework/theme/index.ts +++ b/src/framework/theme/index.ts @@ -48,6 +48,7 @@ export * from './components/chat/chat-message-text.component'; export * from './components/chat/chat-form.component'; export * from './components/chat/chat.module'; export * from './components/spinner/spinner.component'; +export * from './components/spinner/spinner.directive'; export * from './components/spinner/spinner.module'; export * from './components/stepper/stepper.component'; export * from './components/stepper/stepper.module'; @@ -58,5 +59,9 @@ export * from './components/accordion/accordion-item-header.component'; export * from './components/accordion/accordion.module'; export * from './components/button/button.component'; export * from './components/button/button.module'; +export * from './components/list/list.component'; +export * from './components/list/list.module'; +export * from './components/list/list-page-tracker.directive'; +export * from './components/list/infinite-list.directive'; export * from './components/input/input.directive'; export * from './components/input/input.module'; diff --git a/src/framework/theme/package.json b/src/framework/theme/package.json index 5f7f8f1c3b..727d4a8a06 100644 --- a/src/framework/theme/package.json +++ b/src/framework/theme/package.json @@ -30,5 +30,8 @@ "@angular/router": "^6.0.0", "rxjs": "^6.1.0", "bootstrap": "^4.0.0" + }, + "dependencies": { + "intersection-observer": "0.5.0" } } diff --git a/src/framework/theme/styles/global/_components.scss b/src/framework/theme/styles/global/_components.scss index a169e6681b..4e1081f23d 100644 --- a/src/framework/theme/styles/global/_components.scss +++ b/src/framework/theme/styles/global/_components.scss @@ -26,6 +26,7 @@ @import '../../components/stepper/stepper.component.theme'; @import '../../components/accordion/accordion.component.theme'; @import '../../components/button/button.component.theme'; +@import '../../components/list/list.component.theme'; @import '../../components/input/input.directive.theme'; @mixin nb-theme-components() { @@ -52,5 +53,6 @@ @include nb-chat-theme(); @include nb-accordion-theme(); @include nb-buttons-theme(); + @include nb-list-theme(); @include nb-input-theme(); } diff --git a/src/framework/theme/styles/themes/_default.scss b/src/framework/theme/styles/themes/_default.scss index 500063e310..1c86327cfe 100644 --- a/src/framework/theme/styles/themes/_default.scss +++ b/src/framework/theme/styles/themes/_default.scss @@ -582,6 +582,8 @@ $theme: ( accordion-item-fg-text: color-fg-text, accordion-item-shadow: shadow, + list-item-border-color: tabs-separator, + list-item-padding: 1rem, ); // register the theme diff --git a/src/playground/card/card-without-body.component.ts b/src/playground/card/card-without-body.component.ts new file mode 100644 index 0000000000..a23708209e --- /dev/null +++ b/src/playground/card/card-without-body.component.ts @@ -0,0 +1,38 @@ +import { Component } from '@angular/core'; +import { fruits } from '../list/fruits-list'; + +@Component({ + template: ` + + List inside nb-card-body + + + + {{ fruit }} + + + + + + List inside nb-card + + + {{ fruit }} + + + + `, + styles: [` + :host { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + } + nb-card { + min-width: 18rem; + } + `], +}) +export class NbCardWithoutBodyComponent { + fruits = fruits; +} diff --git a/src/playground/infinite-list/infinite-list-scroll-modes.component.scss b/src/playground/infinite-list/infinite-list-scroll-modes.component.scss new file mode 100644 index 0000000000..68dbc39591 --- /dev/null +++ b/src/playground/infinite-list/infinite-list-scroll-modes.component.scss @@ -0,0 +1,12 @@ +:host { + display: flex; +} + +nb-card { + flex: 1 1 45%; + margin: 0 2.5%; + + &.own-scroll { + height: 30rem; + } +} diff --git a/src/playground/infinite-list/infinite-list-scroll-modes.component.ts b/src/playground/infinite-list/infinite-list-scroll-modes.component.ts new file mode 100644 index 0000000000..8e1ef90d84 --- /dev/null +++ b/src/playground/infinite-list/infinite-list-scroll-modes.component.ts @@ -0,0 +1,75 @@ +import { Component } from '@angular/core'; +import { NewsService } from './news.service'; + +@Component({ + template: ` + + + Own scroll + + + + + + + + + + + + + + Window scroll + + + + + + + + + + + `, + styleUrls: [ 'infinite-news-list.component.scss', 'infinite-list-scroll-modes.component.scss' ], + providers: [ NewsService ], +}) +export class NbInfiniteListScrollModesComponent { + + firstCard = { + news: [], + placeholders: [], + loading: false, + pageToLoadNext: 1, + }; + secondCard = { + news: [], + placeholders: [], + loading: false, + pageToLoadNext: 1, + }; + pageSize = 10; + + constructor(private newsService: NewsService) {} + + loadNext(cardData) { + if (cardData.loading) { return } + + cardData.loading = true; + cardData.placeholders = new Array(this.pageSize); + this.newsService.load(cardData.pageToLoadNext, this.pageSize) + .subscribe(nextNews => { + cardData.placeholders = []; + cardData.news.push(...nextNews); + cardData.loading = false; + cardData.pageToLoadNext++; + }); + } +} diff --git a/src/playground/infinite-list/infinite-list-showcase.component.ts b/src/playground/infinite-list/infinite-list-showcase.component.ts new file mode 100644 index 0000000000..0e0e1464e4 --- /dev/null +++ b/src/playground/infinite-list/infinite-list-showcase.component.ts @@ -0,0 +1,47 @@ +import { Component } from '@angular/core'; +import { NewsService } from './news.service'; + +@Component({ + template: ` + + + + + + + + + + + `, + styleUrls: [ 'infinite-news-list.component.scss' ], + providers: [ NewsService ], +}) +export class NbInfiniteListShowcaseComponent { + + news = []; + placeholders = []; + pageSize = 10; + pageToLoadNext = 1; + loading = false; + + constructor(private newsService: NewsService) {} + + loadNext() { + if (this.loading) { return } + + this.loading = true; + this.placeholders = new Array(this.pageSize); + this.newsService.load(this.pageToLoadNext, this.pageSize) + .subscribe(news => { + this.placeholders = []; + this.news.push(...news); + this.loading = false; + this.pageToLoadNext++; + }); + } +} diff --git a/src/playground/infinite-list/infinite-news-list.component.scss b/src/playground/infinite-list/infinite-news-list.component.scss new file mode 100644 index 0000000000..67ad50073c --- /dev/null +++ b/src/playground/infinite-list/infinite-news-list.component.scss @@ -0,0 +1,13 @@ +/deep/ body { + height: 30rem; +} + +:host { + display: block; + margin: 0 auto; + max-width: 50rem; +} + +.nb-spinner-container { + flex: 1 0 4rem; +} diff --git a/src/playground/infinite-list/infinite-news-list.component.ts b/src/playground/infinite-list/infinite-news-list.component.ts new file mode 100644 index 0000000000..69a5ec9507 --- /dev/null +++ b/src/playground/infinite-list/infinite-news-list.component.ts @@ -0,0 +1,135 @@ +import { Component, ViewChildren, ElementRef, QueryList, OnInit, Inject, PLATFORM_ID, OnDestroy } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { take, filter, map } from 'rxjs/operators'; +import { NbListItemComponent, NbLayoutScrollService, NB_WINDOW } from '@nebular/theme'; +import { getElementHeight } from '@nebular/theme/components/helpers'; +import { NewsService } from './news.service'; + +@Component({ + template: ` + +
+ + + + + + + + +
+ `, + styleUrls: [ 'infinite-news-list.component.scss' ], + providers: [ NewsService ], +}) +export class NbInfiniteNewsListComponent implements OnInit, OnDestroy { + + news = []; + placeholders = []; + pageSize = 10; + startPage: number; + pageToLoadNext: number; + loadingNext = false; + loadingPrevious = false; + initialScrollRestoration: ScrollRestoration; + + @ViewChildren(NbListItemComponent, { read: ElementRef }) listItems: QueryList>; + + constructor( + private newsService: NewsService, + private router: Router, + private route: ActivatedRoute, + private scrollService: NbLayoutScrollService, + @Inject(PLATFORM_ID) private platformId, + @Inject(NB_WINDOW) private window, + ) { + if (isPlatformBrowser(this.platformId) && this.window.history.scrollRestoration) { + // Prevent browsers from scrolling down to last scroll position, when navigating back to this page. + // It doesn't make sense here, since list is dynamic and we handle last user position ourselves, + // by storing page number in URL. So for this component, we disable scroll restoration. + // Don't forget to re-enable it in 'OnDestroy', since this configuration preserved for the whole session + // and it will not be reset after page reload. + this.initialScrollRestoration = window.history.scrollRestoration; + history.scrollRestoration = 'manual'; + } + } + + ngOnInit() { + const { page } = this.route.snapshot.queryParams; + this.startPage = page ? Number.parseInt(page, 10) : 1; + this.pageToLoadNext = this.startPage; + } + + ngOnDestroy() { + if (this.initialScrollRestoration) { + this.window.history.scrollRestoration = this.initialScrollRestoration; + } + } + + updateUrl(page) { + this.router.navigate(['.'], { + queryParams: { page }, + replaceUrl: true, + relativeTo: this.route, + queryParamsHandling: 'merge', + }); + } + + loadPrevious() { + if (this.loadingPrevious || this.startPage === 1) { + return; + } + + this.loadingPrevious = true; + this.newsService.load(this.startPage - 1, this.pageSize) + .subscribe(news => { + this.news.unshift(...news); + this.loadingPrevious = false; + this.restoreScrollPosition(); + this.startPage--; + }); + } + + loadNext() { + if (this.loadingNext) { return } + + this.loadingNext = true; + this.placeholders = new Array(this.pageSize); + this.newsService.load(this.pageToLoadNext, this.pageSize) + .subscribe(news => { + this.placeholders = []; + this.news.push(...news); + this.loadingNext = false; + this.pageToLoadNext++; + }); + } + + private restoreScrollPosition() { + const previousFirstItem = this.listItems.length > 0 ? this.listItems.first.nativeElement : null; + + this.listItems.changes + .pipe( + map(() => this.listItems.first.nativeElement), + filter(newFirstItem => newFirstItem !== previousFirstItem), + take(1), + ) + .subscribe(() => { + let heightOfAddedItems = 0; + for (const { nativeElement } of this.listItems.toArray()) { + if (nativeElement === previousFirstItem) { break } + heightOfAddedItems += getElementHeight(nativeElement); + } + this.scrollService.scrollTo(null, heightOfAddedItems); + }); + } +} diff --git a/src/playground/infinite-list/news-post-placeholder.component.scss b/src/playground/infinite-list/news-post-placeholder.component.scss new file mode 100644 index 0000000000..48a2b7a86f --- /dev/null +++ b/src/playground/infinite-list/news-post-placeholder.component.scss @@ -0,0 +1,25 @@ +@import '../styles/themes'; + +:host { + display: block; +} + +.title-placeholder { + height: 1.8rem; + margin-bottom: 0.5rem; + width: 80%; +} +.text-placeholder { + height: 4rem; + margin-bottom: 1rem; +} +.link-placeholder { + height: 1.25rem; + width: 5rem; +} + +@include nb-install-component() { + [class$='placeholder'] { + background: rgba(nb-theme(layout-bg), 0.6); + } +} diff --git a/src/playground/infinite-list/news-post.component.ts b/src/playground/infinite-list/news-post.component.ts new file mode 100644 index 0000000000..94f0ece05e --- /dev/null +++ b/src/playground/infinite-list/news-post.component.ts @@ -0,0 +1,31 @@ +import { Component, Input, HostBinding } from '@angular/core'; +import { NewsPost } from './news.service'; + +@Component({ + selector: 'nb-news-post', + template: ` + + `, +}) +export class NbNewsPostComponent { + @Input() + post: NewsPost; +} + +@Component({ + selector: 'nb-news-post-placeholder', + template: ` +
+
+ + `, + styleUrls: [ './news-post-placeholder.component.scss' ], +}) +export class NbNewsPostPlaceholderComponent { + @HostBinding('attr.aria-label') + label = 'Loading'; +} diff --git a/src/playground/infinite-list/news.service.ts b/src/playground/infinite-list/news.service.ts new file mode 100644 index 0000000000..3180511f81 --- /dev/null +++ b/src/playground/infinite-list/news.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { delay, map } from 'rxjs/operators'; + +const TOTAL_PAGES = 7; + +export class NewsPost { + title: string; + link: string; + creator: string; + text: string; +} + +@Injectable() +export class NewsService { + + constructor(private http: HttpClient) {} + + load(page: number, pageSize: number): Observable { + const startIndex = ((page - 1) % TOTAL_PAGES) * pageSize; + + return this.http + .get('/assets/data/news.json') + .pipe( + map(news => news.splice(startIndex, pageSize)), + delay(1500), + ); + } +} diff --git a/src/playground/list/fruits-list.ts b/src/playground/list/fruits-list.ts new file mode 100644 index 0000000000..7bae676a28 --- /dev/null +++ b/src/playground/list/fruits-list.ts @@ -0,0 +1,13 @@ +export const fruits: string[] = [ + 'Lemons', + 'Raspberries', + 'Strawberries', + 'Blackberries', + 'Kiwis', + 'Grapefruit', + 'Avocado', + 'Watermelon', + 'Cantaloupe', + 'Oranges', + 'Peaches', +]; diff --git a/src/playground/list/simple-list-showcase.component.scss b/src/playground/list/simple-list-showcase.component.scss new file mode 100644 index 0000000000..c897e948a5 --- /dev/null +++ b/src/playground/list/simple-list-showcase.component.scss @@ -0,0 +1,4 @@ +nb-card { + max-width: 20rem; + margin: 0 auto; +} diff --git a/src/playground/list/simple-list-showcase.component.ts b/src/playground/list/simple-list-showcase.component.ts new file mode 100644 index 0000000000..be921cb81a --- /dev/null +++ b/src/playground/list/simple-list-showcase.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; +import { fruits } from './fruits-list'; + +@Component({ + template: ` + + + Some fruits + + + + {{ fruit }} + + + + `, + styleUrls: [ './simple-list-showcase.component.scss' ], +}) +export class NbSimpleListShowcaseComponent { + fruits = fruits; +} diff --git a/src/playground/list/users-list-showcase.component.ts b/src/playground/list/users-list-showcase.component.ts new file mode 100644 index 0000000000..e0439c34c3 --- /dev/null +++ b/src/playground/list/users-list-showcase.component.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; + +@Component({ + template: ` + + + + + + + + + `, + styleUrls: ['./simple-list-showcase.component.scss'], +}) +export class NbUsersListShowcaseComponent { + users: { name: string, title: string }[] = [ + { name: 'Carla Espinosa', title: 'Nurse' }, + { name: 'Bob Kelso', title: 'Doctor of Medicine' }, + { name: 'Janitor', title: 'Janitor' }, + { name: 'Perry Cox', title: 'Doctor of Medicine' }, + { name: 'Ben Sullivan', title: 'Carpenter and photographer' }, + ]; +} diff --git a/src/playground/playground-routing.module.ts b/src/playground/playground-routing.module.ts index 911b568d0d..b0400730a8 100644 --- a/src/playground/playground-routing.module.ts +++ b/src/playground/playground-routing.module.ts @@ -134,6 +134,12 @@ import { NbButtonOutlineComponent } from './button/button-outline.component'; import { NbButtonSizesComponent } from './button/button-sizes.component'; import { NbButtonTypesComponent } from './button/button-types.component'; import { NbButtonFullWidthComponent } from './button/button-full-width.component'; +import { NbSimpleListShowcaseComponent } from './list/simple-list-showcase.component'; +import { NbUsersListShowcaseComponent } from './list/users-list-showcase.component'; +import { NbCardWithoutBodyComponent } from './card/card-without-body.component'; +import { NbInfiniteListShowcaseComponent } from './infinite-list/infinite-list-showcase.component'; +import { NbInfiniteListScrollModesComponent } from './infinite-list/infinite-list-scroll-modes.component'; +import { NbInfiniteNewsListComponent } from './infinite-list/infinite-news-list.component'; import { NbInputsShowcaseComponent } from './input/input-showcase.component'; import { NbInputColorsComponent } from './input/input-colors.component'; import { NbInputSizesComponent } from './input/input-sizes.component'; @@ -286,6 +292,10 @@ export const routes: Routes = [ path: 'card-sizes.component', component: NbCardSizesComponent, }, + { + path: 'card-without-body.component', + component: NbCardWithoutBodyComponent, + }, ], }, { @@ -575,6 +585,36 @@ export const routes: Routes = [ }, ], }, + { + path: 'list', + children: [ + { + path: 'simple-list-showcase.component', + component: NbSimpleListShowcaseComponent, + }, + { + path: 'users-list-showcase.component', + component: NbUsersListShowcaseComponent, + }, + ], + }, + { + path: 'infinite-list', + children: [ + { + path: 'infinite-list-showcase.component', + component: NbInfiniteListShowcaseComponent, + }, + { + path: 'infinite-list-scroll-modes.component', + component: NbInfiniteListScrollModesComponent, + }, + { + path: 'infinite-news-list.component', + component: NbInfiniteNewsListComponent, + }, + ], + }, { path: 'input', children: [ diff --git a/src/playground/playground.module.ts b/src/playground/playground.module.ts index 4d22a96760..806dc5eeb4 100644 --- a/src/playground/playground.module.ts +++ b/src/playground/playground.module.ts @@ -29,6 +29,7 @@ import { NbSpinnerModule, NbStepperModule, NbAccordionModule, + NbListModule, NbButtonModule, NbInputModule, } from '@nebular/theme'; @@ -164,6 +165,13 @@ import { NbButtonOutlineComponent } from './button/button-outline.component'; import { NbButtonSizesComponent } from './button/button-sizes.component'; import { NbButtonTypesComponent } from './button/button-types.component'; import { NbButtonFullWidthComponent } from './button/button-full-width.component'; +import { NbSimpleListShowcaseComponent } from './list/simple-list-showcase.component'; +import { NbUsersListShowcaseComponent } from './list/users-list-showcase.component'; +import { NbCardWithoutBodyComponent } from './card/card-without-body.component'; +import { NbInfiniteListShowcaseComponent } from './infinite-list/infinite-list-showcase.component' +import { NbInfiniteListScrollModesComponent } from './infinite-list/infinite-list-scroll-modes.component' +import { NbInfiniteNewsListComponent } from './infinite-list/infinite-news-list.component' +import { NbNewsPostPlaceholderComponent, NbNewsPostComponent } from './infinite-list/news-post.component'; import { NbInputsShowcaseComponent } from './input/input-showcase.component'; import { NbInputColorsComponent } from './input/input-colors.component'; import { NbInputSizesComponent } from './input/input-sizes.component'; @@ -199,6 +207,7 @@ export const NB_MODULES = [ NbSpinnerModule, NbAccordionModule, NbButtonModule, + NbListModule, NbInputModule, ]; @@ -243,6 +252,7 @@ export const NB_EXAMPLE_COMPONENTS = [ NbCardFullComponent, NbCardColorsComponent, NbCardAccentsComponent, + NbCardWithoutBodyComponent, NbCardSizesComponent, NbCardTestComponent, NbFlipCardShowcaseComponent, @@ -325,6 +335,13 @@ export const NB_EXAMPLE_COMPONENTS = [ NbButtonSizesComponent, NbButtonTypesComponent, NbButtonFullWidthComponent, + NbSimpleListShowcaseComponent, + NbUsersListShowcaseComponent, + NbInfiniteNewsListComponent, + NbInfiniteListShowcaseComponent, + NbInfiniteListScrollModesComponent, + NbNewsPostComponent, + NbNewsPostPlaceholderComponent, NbInputsShowcaseComponent, NbInputColorsComponent, NbInputSizesComponent, @@ -334,7 +351,6 @@ export const NB_EXAMPLE_COMPONENTS = [ NbScrollWindowComponent, ]; - @NgModule({ imports: [ CommonModule,