diff --git a/AGENTS.md b/AGENTS.md
index 1b2ec300..ea4fad25 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -123,7 +123,7 @@ Apply these rules to all new or modified Angular code:
- **Angular 21.x** with standalone components (no NgModules pattern)
- **TypeScript 5.9.3**
-- **Bootstrap 5.3** + **Angular Material 21.x** (UI components)
+- **Angular Material 21.x** (UI components). The library has no Bootstrap dependency: button directives and components keep their Bootstrap class names and ship their own CSS for them. Library CSS sits in the `ng-kit` cascade layer. The demo still loads Bootstrap 5.3 for its own layout.
- **RxJS 7.x**
### Package manager
diff --git a/projects/ng-kit-demo/e2e/home.spec.ts b/projects/ng-kit-demo/e2e/home.spec.ts
index 69941f42..eb2dea45 100644
--- a/projects/ng-kit-demo/e2e/home.spec.ts
+++ b/projects/ng-kit-demo/e2e/home.spec.ts
@@ -3,11 +3,11 @@ import { expect, test } from '@playwright/test';
test('has success alert', async ({ page }) => {
await page.goto('/alert-demo');
await expect(page.getByText('Success Alert')).toHaveCount(2);
- await expect(page.locator('.btn-close')).toHaveCount(2);
+ await expect(page.getByRole('button', { name: 'Close', exact: true })).toHaveCount(2);
});
test('has error alert', async ({ page }) => {
await page.goto('/alert-demo');
await expect(page.getByText('Error Alert')).toHaveCount(2);
- await expect(page.locator('.btn-close')).toHaveCount(2);
+ await expect(page.getByRole('button', { name: 'Close', exact: true })).toHaveCount(2);
});
diff --git a/projects/ng-kit-demo/src/app/pages/alert/alert.page.ts b/projects/ng-kit-demo/src/app/pages/alert/alert.page.ts
index 20be21b8..34b7c9e9 100644
--- a/projects/ng-kit-demo/src/app/pages/alert/alert.page.ts
+++ b/projects/ng-kit-demo/src/app/pages/alert/alert.page.ts
@@ -46,8 +46,8 @@ const alertTypesConfig = getAlertDemoConfig();
template: `
- Fully customizable, reusable alert component styled with Bootstrap CSS. Designed for standalone usage, tree-shakable imports, and
- reactive state via Angular signals. Configure the alert type, visibility, dismiss behavior, and custom classes.
+ Fully customizable, reusable alert component with its own styles. Designed for standalone usage, tree-shakable imports, and reactive
+ state via Angular signals. Configure the alert type, visibility, dismiss behavior, and custom classes.
@@ -165,7 +165,7 @@ const alertTypesConfig = getAlertDemoConfig();
-
Requires Bootstrap styles to be loaded for the alert to render correctly.
+
No Bootstrap styles are required. The existing alert and alert-* classes remain.
`,
diff --git a/projects/ng-kit-demo/src/app/pages/installation/installation.page.ts b/projects/ng-kit-demo/src/app/pages/installation/installation.page.ts
index 74d6fed8..75bdc6f6 100644
--- a/projects/ng-kit-demo/src/app/pages/installation/installation.page.ts
+++ b/projects/ng-kit-demo/src/app/pages/installation/installation.page.ts
@@ -59,7 +59,7 @@ npm install`;
Angular : Version 19 or later
RxJS : Version 7.x
- Bootstrap : Version 5.x (if using)
+ Bootstrap : Not required by the library
Angular Material : Required for Material-based components
@@ -118,8 +118,12 @@ npm install`;
installed.
Clean install : Delete node_modules and lock files, then reinstall dependencies.
- Bootstrap styles : Verify Bootstrap CSS is properly imported if components appear unstyled.
+ Library styles : Ensure the application allows component styles if buttons or alerts appear unstyled.
Angular Material : Ensure Angular Material is installed if using Material-based components.
+
+ CSS layers : If your application uses layers, declare ng-kit before your utility layer, for example
+ @layer components, ng-kit, utilities; near the top of your stylesheet.
+
TypeScript errors : Ensure you're using a compatible TypeScript version (check Angular requirements).
Update guide : Review the Angular Update Guide for breaking changes between versions.
diff --git a/projects/ng-kit-demo/src/app/pages/introduction/introduction.page.ts b/projects/ng-kit-demo/src/app/pages/introduction/introduction.page.ts
index 0d689656..08f52364 100644
--- a/projects/ng-kit-demo/src/app/pages/introduction/introduction.page.ts
+++ b/projects/ng-kit-demo/src/app/pages/introduction/introduction.page.ts
@@ -12,7 +12,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
Introduction
@js-smart/ng-kit is a comprehensive collection of reusable Angular components, directives, and utilities built
- with Angular Material and Bootstrap 5.x.
+ with Angular Material. The library provides its own button and alert styles without requiring Bootstrap.
@@ -28,8 +28,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
Key features
- Low dependencies — only one third-party dependency (Bootstrap). Fewer dependencies reduce security risk, simplify
- updates, and prevent version conflicts.
+ No Bootstrap dependency — button directives and alerts include their own styles, so consuming applications do not
+ need Bootstrap CSS.
Built for modern Angular — designed around Angular Signals and standalone components. Not a legacy port — built
diff --git a/projects/ng-kit-demo/src/app/pages/spinner/spinner.page.ts b/projects/ng-kit-demo/src/app/pages/spinner/spinner.page.ts
index 31c58a01..038bcb75 100644
--- a/projects/ng-kit-demo/src/app/pages/spinner/spinner.page.ts
+++ b/projects/ng-kit-demo/src/app/pages/spinner/spinner.page.ts
@@ -11,7 +11,7 @@ import { SpinnerComponent } from '@js-smart/ng-kit';
selector: 'app-spinner-demo',
imports: [SpinnerComponent],
template: \`
-
+
\`,
})
export class SpinnerDemoComponent {}`;
@@ -23,7 +23,7 @@ import { SpinnerComponent } from '@js-smart/ng-kit';
selector: 'app-spinner-default',
imports: [SpinnerComponent],
template: \`
-
+
\`,
})
export class SpinnerDefaultComponent {}`;
@@ -56,7 +56,7 @@ const materialConfig = buildDemoConfig({
A thin wrapper around the Angular Material progress spinner. Provides a simple way to customize size, color, and stroke width, with
- an optional Bootstrap spinner fallback.
+ an indeterminate loading indicator without requiring Bootstrap.
@@ -64,9 +64,8 @@ const materialConfig = buildDemoConfig({
Overview
Import SpinnerComponent and drop the <spinner> element into any standalone component. By default
- bootstrapSpinner is true, which renders a Bootstrap spinner (requires Bootstrap CSS). Set
- bootstrapSpinner to false to render the Angular Material mat-spinner instead, then tune
- it with diameter, color, and strokeWidth.
+ The component always renders an indeterminate Angular Material progress spinner. Tune it with diameter,
+ color, and strokeWidth.
Tree-shakable: only the imported features are included in your bundle.
@@ -81,20 +80,20 @@ const materialConfig = buildDemoConfig({
title="Material spinner"
anchorId="material-spinner"
description="The Angular Material spinner with default diameter, primary color, and default stroke width."
- [props]="['bootstrapSpinner']"
+ [props]="[]"
[code]="defaultCode"
[stackblitz]="defaultConfig">
-
+
-
+
@@ -117,7 +116,7 @@ const materialConfig = buildDemoConfig({
bootstrapSpinner
boolean
true
- Use the Bootstrap spinner when true, otherwise the Angular Material spinner
+ Deprecated compatibility input; has no effect
diameter
@@ -140,8 +139,8 @@ const materialConfig = buildDemoConfig({
- Set bootstrapSpinner to false to use the Angular Material spinner. The Bootstrap variant requires
- Bootstrap CSS to be present in your application.
+ The default changed from a Bootstrap spinner to an Angular Material spinner. Existing
+ [bootstrapSpinner] bindings still compile but do not change the result.
diff --git a/projects/ng-kit/src/lib/components/alert/alert.component.css b/projects/ng-kit/src/lib/components/alert/alert.component.css
new file mode 100644
index 00000000..022ae4a8
--- /dev/null
+++ b/projects/ng-kit/src/lib/components/alert/alert.component.css
@@ -0,0 +1,119 @@
+/*
+ * Alert styles: the Bootstrap 5 alert look, without Bootstrap. The existing class names are scoped to this component's
+ * host. Colours use Bootstrap's CSS variables when present and fall back to Bootstrap 5's defaults otherwise.
+ */
+@layer ng-kit {
+ :is(alert, lib-alert) > .row {
+ display: flex;
+ flex-wrap: wrap;
+ margin-inline: -0.75rem;
+ }
+
+ :is(alert, lib-alert) > .row > .col-xs-12 {
+ box-sizing: border-box;
+ width: 100%;
+ margin-inline: auto;
+ padding-inline: 0.75rem;
+ }
+
+ :is(alert, lib-alert) .alert {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ width: 100%;
+ margin-bottom: 1rem;
+ padding: 1rem 3rem 1rem 1rem;
+ line-height: 1.5;
+ color: var(--ngk-alert-color);
+ background-color: var(--ngk-alert-bg);
+ border: 1px solid var(--ngk-alert-border-color);
+ border-radius: 0.375rem;
+ }
+
+ /* Full width on small screens, shrink-wrapped and centred from 768px up */
+ @media (width >= 768px) {
+ :is(alert, lib-alert) > .row > .col-md-auto {
+ width: auto;
+ }
+ }
+
+ :is(alert, lib-alert) .alert-primary {
+ --ngk-alert-color: var(--bs-primary-text-emphasis, #052c65);
+ --ngk-alert-bg: var(--bs-primary-bg-subtle, #cfe2ff);
+ --ngk-alert-border-color: var(--bs-primary-border-subtle, #9ec5fe);
+ }
+
+ :is(alert, lib-alert) .alert-secondary {
+ --ngk-alert-color: var(--bs-secondary-text-emphasis, #2b2f32);
+ --ngk-alert-bg: var(--bs-secondary-bg-subtle, #e2e3e5);
+ --ngk-alert-border-color: var(--bs-secondary-border-subtle, #c4c8cb);
+ }
+
+ :is(alert, lib-alert) .alert-success {
+ --ngk-alert-color: var(--bs-success-text-emphasis, #0a3622);
+ --ngk-alert-bg: var(--bs-success-bg-subtle, #d1e7dd);
+ --ngk-alert-border-color: var(--bs-success-border-subtle, #a3cfbb);
+ }
+
+ :is(alert, lib-alert) .alert-info {
+ --ngk-alert-color: var(--bs-info-text-emphasis, #055160);
+ --ngk-alert-bg: var(--bs-info-bg-subtle, #cff4fc);
+ --ngk-alert-border-color: var(--bs-info-border-subtle, #9eeaf9);
+ }
+
+ :is(alert, lib-alert) .alert-warning {
+ --ngk-alert-color: var(--bs-warning-text-emphasis, #664d03);
+ --ngk-alert-bg: var(--bs-warning-bg-subtle, #fff3cd);
+ --ngk-alert-border-color: var(--bs-warning-border-subtle, #ffe69c);
+ }
+
+ :is(alert, lib-alert) .alert-danger {
+ --ngk-alert-color: var(--bs-danger-text-emphasis, #58151c);
+ --ngk-alert-bg: var(--bs-danger-bg-subtle, #f8d7da);
+ --ngk-alert-border-color: var(--bs-danger-border-subtle, #f1aeb5);
+ }
+
+ :is(alert, lib-alert) .alert-light {
+ --ngk-alert-color: var(--bs-light-text-emphasis, #495057);
+ --ngk-alert-bg: var(--bs-light-bg-subtle, #fcfcfd);
+ --ngk-alert-border-color: var(--bs-light-border-subtle, #e9ecef);
+ }
+
+ :is(alert, lib-alert) .alert-dark {
+ --ngk-alert-color: var(--bs-dark-text-emphasis, #495057);
+ --ngk-alert-bg: var(--bs-dark-bg-subtle, #ced4da);
+ --ngk-alert-border-color: var(--bs-dark-border-subtle, #adb5bd);
+ }
+
+ :is(alert, lib-alert) .btn-close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 2;
+ box-sizing: content-box;
+ width: 1em;
+ height: 1em;
+ margin: 0;
+ padding: 1.25rem 1rem;
+ font: inherit;
+ cursor: pointer;
+ background: transparent
+ url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e")
+ center / 1em auto no-repeat;
+ border: 0;
+ border-radius: 0.375rem;
+ opacity: 0.5;
+
+ &:hover {
+ opacity: 0.75;
+ }
+
+ &:focus-visible {
+ outline: 0;
+ box-shadow: 0 0 0 0.25rem rgb(13 110 253 / 25%);
+ opacity: 1;
+ }
+ }
+}
diff --git a/projects/ng-kit/src/lib/components/alert/alert.component.html b/projects/ng-kit/src/lib/components/alert/alert.component.html
index c08ecbd0..bfcf0df4 100644
--- a/projects/ng-kit/src/lib/components/alert/alert.component.html
+++ b/projects/ng-kit/src/lib/components/alert/alert.component.html
@@ -4,7 +4,7 @@
@if (dismissible()) {
-
+
}
diff --git a/projects/ng-kit/src/lib/components/alert/alert.component.scss b/projects/ng-kit/src/lib/components/alert/alert.component.scss
deleted file mode 100644
index 316fe500..00000000
--- a/projects/ng-kit/src/lib/components/alert/alert.component.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-.alert_div {
- display: flex;
- align-items: center;
- justify-content: center;
-}
diff --git a/projects/ng-kit/src/lib/components/alert/alert.component.spec.ts b/projects/ng-kit/src/lib/components/alert/alert.component.spec.ts
index e4591471..6582e4ec 100644
--- a/projects/ng-kit/src/lib/components/alert/alert.component.spec.ts
+++ b/projects/ng-kit/src/lib/components/alert/alert.component.spec.ts
@@ -10,10 +10,20 @@ import { AlertComponent, AlertType } from './alert.component';
})
class TestAlertComponent {}
+const ALERT_TYPES: AlertType[] = ['info', 'primary', 'secondary', 'success', 'warning', 'danger', 'dark', 'light'];
+
describe('AlertComponent', () => {
let component: AlertComponent;
let fixture: ComponentFixture;
+ function alertElement(): HTMLElement | null {
+ return (fixture.nativeElement as HTMLElement).querySelector('[role="alert"]');
+ }
+
+ function closeButton(): HTMLButtonElement | null {
+ return (fixture.nativeElement as HTMLElement).querySelector('button[aria-label="Close"]');
+ }
+
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AlertComponent, TestAlertComponent],
@@ -36,56 +46,81 @@ describe('AlertComponent', () => {
expect(component.dismissTimeout()).toBe(5000);
});
- it('should apply correct bootstrap class based on type', () => {
- const types: AlertType[] = ['success', 'danger', 'warning', 'info', 'primary', 'secondary', 'dark', 'light'];
+ it.each(ALERT_TYPES)('should apply the alert-{type} class for type %s', (type: AlertType) => {
+ fixture.componentRef.setInput('type', type);
+ fixture.detectChanges();
- types.forEach((type) => {
- fixture.componentRef.setInput('type', type);
- fixture.detectChanges();
- const alertDiv = fixture.debugElement.query(By.css('.alert'));
- expect(alertDiv.nativeElement.classList).toContain(`alert-${type}`);
- });
+ const classList: DOMTokenList | undefined = alertElement()?.classList;
+ expect(classList).toContain('alert');
+ expect(classList).toContain('alert-dismissible');
+ expect(classList).toContain('alert_div');
+ expect(classList).toContain(`alert-${type}`);
+ // exactly one type class at a time
+ expect(Array.from(classList ?? []).filter((c) => /^alert-(info|primary|secondary|success|warning|danger|dark|light)$/.test(c))).toEqual(
+ [`alert-${type}`],
+ );
});
- it('should add custom classes from class input', () => {
+ it('should preserve the existing layout classes', () => {
+ const container: HTMLElement | null = (fixture.nativeElement as HTMLElement).querySelector('.row');
+ const column: HTMLElement | null = container?.querySelector('.col-xs-12.col-sm-12.col-md-auto.mx-auto') ?? null;
+ expect(container).not.toBeNull();
+ expect(column).not.toBeNull();
+ });
+
+ it('should add custom classes from class input to the container', () => {
fixture.componentRef.setInput('class', 'custom-class-1 custom-class-2');
fixture.detectChanges();
- const rowDiv = fixture.debugElement.query(By.css('.row'));
- expect(rowDiv.nativeElement.classList).toContain('custom-class-1');
- expect(rowDiv.nativeElement.classList).toContain('custom-class-2');
+
+ const container: HTMLElement | null = (fixture.nativeElement as HTMLElement).querySelector('.row');
+ expect(container).not.toBeNull();
+ expect(container?.classList).toContain('custom-class-1');
+ expect(container?.classList).toContain('custom-class-2');
+ expect(alertElement()?.classList).not.toContain('custom-class-1');
});
it('should not render when open signal is false', () => {
component.open.set(false);
fixture.detectChanges();
- const alertDiv = fixture.debugElement.query(By.css('.alert'));
- expect(alertDiv).toBeNull();
+
+ expect(alertElement()).toBeNull();
});
it('should show close button by default and hide it when dismissible is false', () => {
- let closeButton = fixture.debugElement.query(By.css('.btn-close'));
- expect(closeButton).toBeTruthy();
+ expect(closeButton()).not.toBeNull();
+ expect(closeButton()?.type).toBe('button');
+ expect(closeButton()?.classList).toContain('btn-close');
fixture.componentRef.setInput('dismissible', false);
fixture.detectChanges();
- closeButton = fixture.debugElement.query(By.css('.btn-close'));
- expect(closeButton).toBeNull();
+
+ expect(closeButton()).toBeNull();
});
it('should call closeAlert and emit closed output when close button is clicked', () => {
const closedSpy = vi.spyOn(component.closed, 'emit');
- const closeButton = fixture.debugElement.query(By.css('.btn-close'));
- closeButton.nativeElement.click();
+ closeButton()?.click();
+ fixture.detectChanges();
expect(component.open()).toBe(false);
- expect(closedSpy).toHaveBeenCalled();
+ expect(closedSpy).toHaveBeenCalledTimes(1);
+ expect(alertElement()).toBeNull();
+ });
+
+ it('should emit closed only once when closeAlert is called repeatedly', () => {
+ const closedSpy = vi.spyOn(component.closed, 'emit');
+ component.closeAlert();
+ component.closeAlert();
+
+ expect(closedSpy).toHaveBeenCalledTimes(1);
});
it('should project content', () => {
const hostFixture = TestBed.createComponent(TestAlertComponent);
hostFixture.detectChanges();
- const alertElement = hostFixture.debugElement.query(By.css('.alert'));
- expect(alertElement.nativeElement.textContent).toContain('Test Content');
+ const alert: HTMLElement = hostFixture.debugElement.query(By.css('[role="alert"]')).nativeElement;
+
+ expect(alert.textContent).toContain('Test Content');
});
describe('Timeout Logic (Zoneless)', () => {
@@ -131,8 +166,10 @@ describe('AlertComponent', () => {
});
it('should have accessibility attributes', () => {
- const alertDiv = fixture.debugElement.query(By.css('.alert'));
- expect(alertDiv.attributes['role']).toBe('alert');
- expect(alertDiv.attributes['aria-live']).toBe('polite');
+ const alert: HTMLElement | null = alertElement();
+
+ expect(alert?.getAttribute('role')).toBe('alert');
+ expect(alert?.getAttribute('aria-live')).toBe('polite');
+ expect(closeButton()?.getAttribute('aria-label')).toBe('Close');
});
});
diff --git a/projects/ng-kit/src/lib/components/alert/alert.component.ts b/projects/ng-kit/src/lib/components/alert/alert.component.ts
index cd38aa60..5903b2f2 100644
--- a/projects/ng-kit/src/lib/components/alert/alert.component.ts
+++ b/projects/ng-kit/src/lib/components/alert/alert.component.ts
@@ -1,9 +1,24 @@
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, inject, input, type OnInit, output, signal } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ effect,
+ inject,
+ input,
+ type OnInit,
+ output,
+ signal,
+ ViewEncapsulation,
+} from '@angular/core';
export type AlertType = 'info' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'dark' | 'light';
/**
- * Boostrap Alert component that can be used to alert messages to the user
+ * Alert component that shows a message to the user. It is dismissible by default and closes itself after a timeout.
+ *
+ * Styling is self-contained and needs no CSS framework. Colours come from Bootstrap's CSS variables when Bootstrap is
+ * loaded, otherwise from Bootstrap 5's defaults; override `--ngk-alert-color`, `--ngk-alert-bg` and
+ * `--ngk-alert-border-color` on an `.alert-{type}` class to theme a type.
*
* @author Pavan Kumar Jadda
* @since 12.0.0
@@ -11,13 +26,16 @@ export type AlertType = 'info' | 'primary' | 'secondary' | 'success' | 'warning'
@Component({
selector: 'lib-alert, alert',
templateUrl: './alert.component.html',
- styleUrls: ['./alert.component.scss'],
+ styleUrl: './alert.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
+ // Global styles use the existing Bootstrap class names, scoped to the component host.
+ encapsulation: ViewEncapsulation.None,
})
export class AlertComponent implements OnInit {
cdr = inject(ChangeDetectorRef);
/**
- * Type of the BootStrap Alert. Following values are supported. See BootStrap docs for more information
+ * Type of the alert, which selects its colours. Supported values: `info` (default), `primary`, `secondary`, `success`,
+ * `warning`, `danger`, `dark` and `light`
*/
type = input('info');
@@ -47,7 +65,7 @@ export class AlertComponent implements OnInit {
dismissTimeout = input(5000);
/**
- * Additional classes to be added to the alert. This can be used to add custom styles to the alert
+ * Additional classes to be added to the alert container. This can be used to add custom styles to the alert
*/
class = input('');
@@ -79,7 +97,7 @@ export class AlertComponent implements OnInit {
}
/**
- * Closes BootStrap Alert if not open
+ * Closes the alert and emits `closed`. Does nothing if the alert is already closed
*
* @author Pavan Kumar Jadda
* @since 12.0.0
diff --git a/projects/ng-kit/src/lib/components/buttons/base-button/base-button.directive.ts b/projects/ng-kit/src/lib/components/buttons/base-button/base-button.directive.ts
index dc52604a..0cb51dbb 100644
--- a/projects/ng-kit/src/lib/components/buttons/base-button/base-button.directive.ts
+++ b/projects/ng-kit/src/lib/components/buttons/base-button/base-button.directive.ts
@@ -1,4 +1,5 @@
import { booleanAttribute, computed, Directive, DOCUMENT, effect, ElementRef, inject, input, OnInit, signal } from '@angular/core';
+import { injectButtonStyles } from '../button-styles.component';
@Directive({
host: {
@@ -21,6 +22,7 @@ export abstract class BaseButtonDirective implements OnInit {
protected iconSpan = signal(null);
constructor() {
+ injectButtonStyles();
this.elementRef.nativeElement.classList.add('btn');
effect(() => {
diff --git a/projects/ng-kit/src/lib/components/buttons/bs-link-button/bs-link-button.directive.ts b/projects/ng-kit/src/lib/components/buttons/bs-link-button/bs-link-button.directive.ts
index b14959a2..5cd7dfa1 100644
--- a/projects/ng-kit/src/lib/components/buttons/bs-link-button/bs-link-button.directive.ts
+++ b/projects/ng-kit/src/lib/components/buttons/bs-link-button/bs-link-button.directive.ts
@@ -1,4 +1,5 @@
import { Directive, DOCUMENT, effect, ElementRef, inject, input } from '@angular/core';
+import { injectButtonStyles } from '../button-styles.component';
@Directive({
selector: '[bsLinkButton]',
@@ -12,6 +13,7 @@ export class BsLinkButtonDirective {
private readonly iconElement = this.document.createElement('span');
constructor() {
+ injectButtonStyles();
const element = this.elementRef.nativeElement;
element.classList.add('btn', 'text-primary');
this.iconElement.classList.add('material-icons', 'pe-2');
diff --git a/projects/ng-kit/src/lib/components/buttons/button-directives.spec.ts b/projects/ng-kit/src/lib/components/buttons/button-directives.spec.ts
new file mode 100644
index 00000000..8ce7787a
--- /dev/null
+++ b/projects/ng-kit/src/lib/components/buttons/button-directives.spec.ts
@@ -0,0 +1,79 @@
+import { Component, DOCUMENT, signal } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import {
+ BsLinkButtonDirective,
+ EditBsButtonDirective,
+ EditSvgIconButtonDirective,
+ ManageButtonDirective,
+ PrimaryButtonDirective,
+ ViewButtonDirective,
+} from '@js-smart/ng-kit';
+
+@Component({
+ imports: [
+ PrimaryButtonDirective,
+ ViewButtonDirective,
+ BsLinkButtonDirective,
+ EditBsButtonDirective,
+ EditSvgIconButtonDirective,
+ ManageButtonDirective,
+ ],
+ template: `
+ Save
+ View
+ Link
+ Edit
+ Edit
+ Manage
+ `,
+})
+class TestHostComponent {
+ loading = signal(false);
+}
+
+describe('Button directives', () => {
+ let fixture: ComponentFixture;
+ let host: HTMLElement;
+
+ const button = (id: string): HTMLElement => host.querySelector(`[data-testid="${id}"]`) as HTMLElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [TestHostComponent] }).compileComponents();
+ fixture = TestBed.createComponent(TestHostComponent);
+ host = fixture.nativeElement;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ });
+
+ it('should keep the class names the directives have always emitted', () => {
+ expect(Array.from(button('primary').classList)).toEqual(expect.arrayContaining(['btn', 'btn-primary', 'primary-button']));
+ expect(Array.from(button('view').classList)).toContain('btn');
+ expect(button('view').classList).not.toContain('btn-primary');
+ expect(Array.from(button('link').classList)).toEqual(expect.arrayContaining(['btn', 'text-primary']));
+ expect(Array.from(button('edit-bs').classList)).toEqual(expect.arrayContaining(['btn', 'text-primary', 'gap-1']));
+ expect(Array.from(button('edit-svg').classList)).toEqual(expect.arrayContaining(['primary-button', 'gap-1']));
+ expect(Array.from(button('manage').classList)).toEqual(expect.arrayContaining(['btn', 'mr-3', 'secondary-button']));
+ });
+
+ it('should mark icons with the spacing class', () => {
+ expect(button('primary').querySelector('mat-icon')?.classList).toContain('pe-2');
+ expect(button('link').querySelector('.material-icons')?.classList).toContain('pe-2');
+ });
+
+ it('should render the inline spinner while loading', () => {
+ fixture.componentInstance.loading.set(true);
+ fixture.detectChanges();
+
+ const spinner = button('primary').querySelector('[role="status"]');
+ expect(Array.from(spinner?.classList ?? [])).toEqual(['spinner-border', 'spinner-border-sm', 'me-2']);
+ expect(button('primary').textContent).toContain('Saving...');
+ });
+
+ it('should add the button stylesheet to the document head once', () => {
+ const styles = Array.from(TestBed.inject(DOCUMENT).head.querySelectorAll('style')).filter((style) =>
+ style.textContent?.includes('.btn-primary'),
+ );
+ expect(styles.length).toBe(1);
+ expect(styles[0].textContent).toContain('@layer ng-kit');
+ });
+});
diff --git a/projects/ng-kit/src/lib/components/buttons/button-styles.component.ts b/projects/ng-kit/src/lib/components/buttons/button-styles.component.ts
new file mode 100644
index 00000000..943a17cb
--- /dev/null
+++ b/projects/ng-kit/src/lib/components/buttons/button-styles.component.ts
@@ -0,0 +1,25 @@
+import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core';
+import { NgKitStyleLoader } from '../../styles/style-loader';
+
+/**
+ * Style-only component that carries the CSS used by the button directives. It is never rendered; see {@link injectButtonStyles}.
+ *
+ * @internal
+ */
+@Component({
+ selector: 'ngk-style-carrier',
+ template: '',
+ styleUrl: './button-styles.css',
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class NgKitButtonStyles {}
+
+/**
+ * Adds the button directive styles to the document (once per application). Must be called in an injection context.
+ *
+ * @internal
+ */
+export function injectButtonStyles(): void {
+ inject(NgKitStyleLoader).load(NgKitButtonStyles);
+}
diff --git a/projects/ng-kit/src/lib/components/buttons/button-styles.css b/projects/ng-kit/src/lib/components/buttons/button-styles.css
new file mode 100644
index 00000000..98a6ca12
--- /dev/null
+++ b/projects/ng-kit/src/lib/components/buttons/button-styles.css
@@ -0,0 +1,89 @@
+/*
+ * Button directive styles: the Bootstrap 5 button look, without Bootstrap, under the class names the directives have
+ * always emitted. Everything sits in the `ng-kit` cascade layer, so normal unlayered CSS from Angular Material,
+ * Bootstrap, and the application takes precedence.
+ */
+@layer ng-kit {
+ .btn {
+ display: inline-block;
+ padding: 0.375rem 0.75rem;
+ font-family: inherit;
+ font-size: 1rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: #212529;
+ text-align: center;
+ text-decoration: none;
+ vertical-align: middle;
+ cursor: pointer;
+ user-select: none;
+ background-color: transparent;
+ border: 1px solid transparent;
+ border-radius: 0.375rem;
+ transition:
+ color 0.15s ease-in-out,
+ background-color 0.15s ease-in-out,
+ border-color 0.15s ease-in-out,
+ box-shadow 0.15s ease-in-out;
+
+ &:focus-visible {
+ outline: 0;
+ box-shadow: 0 0 0 0.25rem rgb(13 110 253 / 25%);
+ }
+
+ &:disabled {
+ pointer-events: none;
+ opacity: 0.65;
+ }
+ }
+
+ .btn-primary {
+ color: #fff;
+ background-color: #0d6efd;
+ border-color: #0d6efd;
+
+ &:hover {
+ background-color: #0b5ed7;
+ border-color: #0a58ca;
+ }
+
+ &:focus-visible {
+ box-shadow: 0 0 0 0.25rem rgb(49 132 253 / 50%);
+ }
+ }
+
+ /* `!important` like Bootstrap's utility, so it also colours Material buttons */
+ .btn.text-primary {
+ color: rgba(var(--bs-primary-rgb, 13, 110, 253), var(--bs-text-opacity, 1)) !important;
+ }
+
+ /* Space after a leading icon */
+ .btn .pe-2 {
+ box-sizing: border-box;
+ padding-right: 0.5rem;
+ }
+
+ .gap-1 {
+ gap: 0.25rem;
+ }
+
+ /* Loading spinner */
+ .btn .spinner-border {
+ display: inline-block;
+ box-sizing: border-box;
+ width: 1rem;
+ height: 1rem;
+ margin-right: 0.5rem;
+ vertical-align: -0.125em;
+ border: 0.2em solid currentcolor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: ngk-spinner-border 0.75s linear infinite;
+ }
+
+ @keyframes ngk-spinner-border {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+}
diff --git a/projects/ng-kit/src/lib/components/buttons/edit-bs-button/edit-bs-button.directive.ts b/projects/ng-kit/src/lib/components/buttons/edit-bs-button/edit-bs-button.directive.ts
index 002f9575..5d13fd43 100644
--- a/projects/ng-kit/src/lib/components/buttons/edit-bs-button/edit-bs-button.directive.ts
+++ b/projects/ng-kit/src/lib/components/buttons/edit-bs-button/edit-bs-button.directive.ts
@@ -1,6 +1,7 @@
import { ComponentRef, Directive, DOCUMENT, ElementRef, inject, OnDestroy, OnInit, signal, ViewContainerRef } from '@angular/core';
import { mountEditSolidSvgIcon } from '../../../svg-icons/edit-solid-svg/append-edit-solid-svg-icon.util';
import { EditSolidSvgComponent } from '../../../svg-icons/edit-solid-svg/edit-solid-svg.component';
+import { injectButtonStyles } from '../button-styles.component';
@Directive({
selector: '[editBsButton]',
@@ -13,6 +14,10 @@ export class EditBsButtonDirective implements OnInit, OnDestroy {
protected originalText = signal('');
private iconComponentRef: ComponentRef | null = null;
+ constructor() {
+ injectButtonStyles();
+ }
+
ngOnInit(): void {
this.elementRef.nativeElement.classList.add('btn', 'text-primary', 'gap-1');
this.originalText.set(this.elementRef.nativeElement.textContent?.trim() || 'Edit');
diff --git a/projects/ng-kit/src/lib/components/buttons/edit-svg-icon-button/edit-svg-icon-button.directive.ts b/projects/ng-kit/src/lib/components/buttons/edit-svg-icon-button/edit-svg-icon-button.directive.ts
index 92060673..fbbe4718 100644
--- a/projects/ng-kit/src/lib/components/buttons/edit-svg-icon-button/edit-svg-icon-button.directive.ts
+++ b/projects/ng-kit/src/lib/components/buttons/edit-svg-icon-button/edit-svg-icon-button.directive.ts
@@ -1,6 +1,7 @@
import { ComponentRef, Directive, DOCUMENT, ElementRef, inject, OnDestroy, OnInit, signal, ViewContainerRef } from '@angular/core';
import { mountEditSolidSvgIcon } from '../../../svg-icons/edit-solid-svg/append-edit-solid-svg-icon.util';
import { EditSolidSvgComponent } from '../../../svg-icons/edit-solid-svg/edit-solid-svg.component';
+import { injectButtonStyles } from '../button-styles.component';
@Directive({
selector: '[editSvgIconButton]',
@@ -13,6 +14,10 @@ export class EditSvgIconButtonDirective implements OnInit, OnDestroy {
protected originalText = signal('');
private iconComponentRef: ComponentRef | null = null;
+ constructor() {
+ injectButtonStyles();
+ }
+
ngOnInit(): void {
this.elementRef.nativeElement.classList.add('mat-raised-button', 'primary-button', 'gap-1');
this.originalText.set(this.elementRef.nativeElement.textContent?.trim() || 'Edit');
diff --git a/projects/ng-kit/src/lib/components/confirm-dialog/confirm-dialog.component.ts b/projects/ng-kit/src/lib/components/confirm-dialog/confirm-dialog.component.ts
index d3f49ca7..89e8ea0b 100644
--- a/projects/ng-kit/src/lib/components/confirm-dialog/confirm-dialog.component.ts
+++ b/projects/ng-kit/src/lib/components/confirm-dialog/confirm-dialog.component.ts
@@ -8,7 +8,7 @@ import { MatDividerModule } from '@angular/material/divider';
imports: [MatButtonModule, MatDividerModule, MatDialogModule],
template: `
-
{{ title }}
+ {{ title }}
@@ -22,7 +22,16 @@ import { MatDividerModule } from '@angular/material/divider';
`,
changeDetection: ChangeDetectionStrategy.Eager,
- styles: [],
+ styles: `
+ @layer ng-kit {
+ .m-3.mat-headline-5 {
+ margin: 1rem;
+ font-size: 1.75rem;
+ font-weight: 500;
+ line-height: 1.2;
+ }
+ }
+ `,
})
export class ConfirmDialogComponent {
title: string;
diff --git a/projects/ng-kit/src/lib/components/spinner/spinner.component.html b/projects/ng-kit/src/lib/components/spinner/spinner.component.html
index a95db8c0..d140943b 100644
--- a/projects/ng-kit/src/lib/components/spinner/spinner.component.html
+++ b/projects/ng-kit/src/lib/components/spinner/spinner.component.html
@@ -1,11 +1,7 @@
-
-@if (!bootstrapSpinner()) {
-
-}
-
-
-@if (bootstrapSpinner()) {
-
-}
+
diff --git a/projects/ng-kit/src/lib/components/spinner/spinner.component.scss b/projects/ng-kit/src/lib/components/spinner/spinner.component.scss
deleted file mode 100644
index e036eb85..00000000
--- a/projects/ng-kit/src/lib/components/spinner/spinner.component.scss
+++ /dev/null
@@ -1,18 +0,0 @@
-.bs-spinner-sm {
- color: #3f51b5;
- margin-bottom: 20px;
-}
-
-.bs-spinner {
- color: #3f51b5;
- width: 3rem;
- height: 3rem;
- margin-bottom: 20px;
-}
-
-.bs-spinner-lg {
- color: #3f51b5;
- width: 5rem;
- height: 5rem;
- margin-bottom: 20px;
-}
diff --git a/projects/ng-kit/src/lib/components/spinner/spinner.component.spec.ts b/projects/ng-kit/src/lib/components/spinner/spinner.component.spec.ts
new file mode 100644
index 00000000..8ce32835
--- /dev/null
+++ b/projects/ng-kit/src/lib/components/spinner/spinner.component.spec.ts
@@ -0,0 +1,69 @@
+///
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { By } from '@angular/platform-browser';
+import { SpinnerComponent } from './spinner.component';
+
+describe('SpinnerComponent', () => {
+ let fixture: ComponentFixture;
+ let element: HTMLElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [SpinnerComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SpinnerComponent);
+ element = fixture.nativeElement as HTMLElement;
+ fixture.detectChanges();
+ });
+
+ function progressSpinner(): MatProgressSpinner {
+ return fixture.debugElement.query(By.directive(MatProgressSpinner)).componentInstance;
+ }
+
+ it('should render an indeterminate Material progress spinner', () => {
+ const spinner: HTMLElement | null = element.querySelector('mat-progress-spinner');
+
+ expect(spinner).not.toBeNull();
+ expect(spinner?.classList).toContain('mx-auto');
+ expect(progressSpinner().mode).toBe('indeterminate');
+ });
+
+ it('should expose an accessible progressbar', () => {
+ const spinner: HTMLElement | null = element.querySelector('[role="progressbar"]');
+
+ expect(spinner).not.toBeNull();
+ expect(spinner?.getAttribute('aria-label')).toBe('Loading');
+ });
+
+ it('should apply the default diameter, stroke width and color', () => {
+ expect(progressSpinner().diameter).toBe(50);
+ expect(progressSpinner().strokeWidth).toBe(5);
+ expect(progressSpinner().color).toBe('primary');
+ });
+
+ it('should pass diameter, strokeWidth and color through', () => {
+ fixture.componentRef.setInput('diameter', 40);
+ fixture.componentRef.setInput('strokeWidth', 4);
+ fixture.componentRef.setInput('color', 'accent');
+ fixture.detectChanges();
+
+ expect(progressSpinner().diameter).toBe(40);
+ expect(progressSpinner().strokeWidth).toBe(4);
+ expect(progressSpinner().color).toBe('accent');
+ });
+
+ it('should ignore the deprecated bootstrapSpinner input', () => {
+ fixture.componentRef.setInput('bootstrapSpinner', false);
+ fixture.detectChanges();
+
+ expect(element.querySelectorAll('mat-progress-spinner').length).toBe(1);
+ });
+
+ it('should not render the former CSS or Bootstrap spinner markup', () => {
+ for (const legacyClass of ['spinner-border', 'bs-spinner', 'd-flex', 'justify-content-center']) {
+ expect(element.querySelector(`.${legacyClass}`), `unexpected .${legacyClass}`).toBeNull();
+ }
+ });
+});
diff --git a/projects/ng-kit/src/lib/components/spinner/spinner.component.ts b/projects/ng-kit/src/lib/components/spinner/spinner.component.ts
index 058cb3c9..f2e0bc7d 100644
--- a/projects/ng-kit/src/lib/components/spinner/spinner.component.ts
+++ b/projects/ng-kit/src/lib/components/spinner/spinner.component.ts
@@ -1,32 +1,38 @@
-import { Component, input, ChangeDetectionStrategy } from '@angular/core';
+import { ChangeDetectionStrategy, Component, input, ViewEncapsulation } from '@angular/core';
import { ThemePalette } from '@angular/material/core';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatProgressSpinner } from '@angular/material/progress-spinner';
+/**
+ * Centered, indeterminate Angular Material progress spinner.
+ */
@Component({
selector: 'spinner,lib-spinner',
- imports: [MatProgressSpinnerModule],
+ imports: [MatProgressSpinner],
templateUrl: './spinner.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
- styleUrls: ['./spinner.component.scss'],
+ // Global styles keep the existing mx-auto class, scoped to the component host.
+ styles: '@layer ng-kit { :is(spinner, lib-spinner) .mx-auto { margin-inline: auto; } }',
+ encapsulation: ViewEncapsulation.None,
})
export class SpinnerComponent {
/**
- * Use Boostrap Spinner. Default `true`
+ * @deprecated Has no effect: the component always renders the Angular Material progress spinner. Kept so existing
+ * `[bootstrapSpinner]` bindings still compile; it will be removed in a future major version.
*/
bootstrapSpinner = input(true);
/**
- * Diameter of the Angular Material spinner
+ * Diameter of the spinner in pixels. Default `50`
*/
diameter = input(50);
/**
- * Color of the Angular Material spinner
+ * Theme color of the spinner. Default `primary`
*/
color = input('primary');
/**
- * Stroke Width of the Angular Material spinner
+ * Stroke width of the spinner in pixels. Default `5`
*/
strokeWidth = input(5);
}
diff --git a/projects/ng-kit/src/lib/styles/style-loader.spec.ts b/projects/ng-kit/src/lib/styles/style-loader.spec.ts
new file mode 100644
index 00000000..373f2267
--- /dev/null
+++ b/projects/ng-kit/src/lib/styles/style-loader.spec.ts
@@ -0,0 +1,73 @@
+import { Component, DOCUMENT, OnDestroy, ViewEncapsulation } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { NgKitStyleLoader } from './style-loader';
+
+const MARKER = 'ngk-style-loader-spec-marker';
+
+@Component({
+ selector: 'ngk-style-loader-spec-styles',
+ template: '',
+ styles: `
+ .${MARKER} {
+ color: red;
+ }
+ `,
+ encapsulation: ViewEncapsulation.None,
+})
+class TestStyles implements OnDestroy {
+ static instances = 0;
+ static destroyed = 0;
+
+ constructor() {
+ TestStyles.instances++;
+ }
+
+ ngOnDestroy(): void {
+ TestStyles.destroyed++;
+ }
+}
+
+describe('NgKitStyleLoader', () => {
+ let loader: NgKitStyleLoader;
+ let document: Document;
+
+ const markerStyles = (): HTMLStyleElement[] =>
+ Array.from(document.head.querySelectorAll('style')).filter((style) => style.textContent?.includes(MARKER));
+
+ beforeEach(() => {
+ TestStyles.instances = 0;
+ TestStyles.destroyed = 0;
+ loader = TestBed.inject(NgKitStyleLoader);
+ document = TestBed.inject(DOCUMENT);
+ });
+
+ it('should add the component styles to the document head', () => {
+ loader.load(TestStyles);
+
+ expect(markerStyles().length).toBe(1);
+ });
+
+ it('should instantiate a styles component only once', () => {
+ loader.load(TestStyles);
+ loader.load(TestStyles);
+
+ expect(TestStyles.instances).toBe(1);
+ expect(markerStyles().length).toBe(1);
+ });
+
+ it('should not attach the styles component to the DOM', () => {
+ loader.load(TestStyles);
+
+ expect(document.querySelector('ngk-style-loader-spec-styles')).toBeNull();
+ });
+
+ it('should destroy loaded components when the application is destroyed', () => {
+ loader.load(TestStyles);
+ expect(TestStyles.destroyed).toBe(0);
+
+ TestBed.resetTestingModule();
+
+ expect(TestStyles.destroyed).toBe(1);
+ expect(markerStyles().length).toBe(0);
+ });
+});
diff --git a/projects/ng-kit/src/lib/styles/style-loader.ts b/projects/ng-kit/src/lib/styles/style-loader.ts
new file mode 100644
index 00000000..9bd4958c
--- /dev/null
+++ b/projects/ng-kit/src/lib/styles/style-loader.ts
@@ -0,0 +1,34 @@
+import { ComponentRef, createComponent, DestroyRef, EnvironmentInjector, inject, Injectable, Type } from '@angular/core';
+
+/**
+ * Adds the styles of style-only components to the document, once per application.
+ *
+ * Directives cannot declare styles, so a directive that needs CSS asks this service to instantiate a template-less
+ * component using `ViewEncapsulation.None`. Creating the component registers its styles with Angular's shared style
+ * host, which also takes care of CSP nonces and server-side rendering. The component is never attached to the DOM.
+ *
+ * @internal
+ */
+@Injectable({ providedIn: 'root' })
+export class NgKitStyleLoader {
+ private readonly environmentInjector = inject(EnvironmentInjector);
+ private readonly loaded = new Map, ComponentRef>();
+
+ constructor() {
+ inject(DestroyRef).onDestroy(() => {
+ this.loaded.forEach((componentRef) => componentRef.destroy());
+ this.loaded.clear();
+ });
+ }
+
+ /**
+ * Instantiates the given style-only component unless it has already been loaded for this application.
+ *
+ * @param styles Component whose styles should be added to the document
+ */
+ load(styles: Type): void {
+ if (!this.loaded.has(styles)) {
+ this.loaded.set(styles, createComponent(styles, { environmentInjector: this.environmentInjector }));
+ }
+ }
+}