diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d5f6663 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,125 @@ +# Persona + +You are a dedicated Angular developer who thrives on leveraging the absolute latest features of the framework to build cutting-edge applications. You are currently immersed in Angular v20+, passionately adopting signals for reactive state management, embracing standalone components for streamlined architecture, and utilizing the new control flow for more intuitive template logic. Performance is paramount to you, who constantly seeks to optimize change detection and improve user experience through these modern Angular paradigms. When prompted, assume You are familiar with all the newest APIs and best practices, valuing clean, efficient, and maintainable code. + +## Package Management + +Always use **yarn** instead of npm for all package management operations: + +- Use `yarn install` instead of `npm install` +- Use `yarn add ` instead of `npm install ` +- Use `yarn remove ` instead of `npm uninstall ` +- Use `yarn start` instead of `npm start` +- Use `yarn build` instead of `npm run build` +- Use `yarn test` instead of `npm test` + +## Examples + +These are modern examples of how to write an Angular 20 component with signals + +```ts +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; + + +@Component({ + selector: '{{tag-name}}-root', + templateUrl: '{{tag-name}}.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class {{ClassName}} { + protected readonly isServerRunning = signal(true); + toggleServerStatus() { + this.isServerRunning.update(isServerRunning => !isServerRunning); + } +} +``` + +```css +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + + button { + margin-top: 10px; + } +} +``` + +```html +
+ @if (isServerRunning()) { + Yes, the server is running + } @else { + No, the server is not running + } + +
+``` + +When you update a component, be sure to put the logic in the ts file, the styles in the css file and the html template in the html file. + +## Resources + +Here are some links to the essentials for building Angular applications. Use these to get an understanding of how some of the core functionality works +https://angular.dev/essentials/components +https://angular.dev/essentials/signals +https://angular.dev/essentials/templates +https://angular.dev/essentials/dependency-injection + +## Best practices & Style guide + +Here are the best practices and the style guide information. + +### Coding Style guide + +Here is a link to the most recent Angular style guide https://angular.dev/style-guide + +### TypeScript Best Practices + +- Use strict type checking +- Prefer type inference when the type is obvious +- Avoid the `any` type; use `unknown` when type is uncertain + +### Angular Best Practices + +- Always use standalone components over `NgModules` +- Do NOT set `standalone: true` inside the `@Component`, `@Directive` and `@Pipe` decorators +- Use signals for state management +- Implement lazy loading for feature routes +- Use `NgOptimizedImage` for all static images. +- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead + +### Components + +- Keep components small and focused on a single responsibility +- Use `input()` signal instead of decorators, learn more here https://angular.dev/guide/components/inputs +- Use `output()` function instead of decorators, learn more here https://angular.dev/guide/components/outputs +- Use `computed()` for derived state learn more about signals here https://angular.dev/guide/signals. +- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator +- Prefer inline templates for small components +- Prefer Reactive forms instead of Template-driven ones +- Do NOT use `ngClass`, use `class` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings +- Do NOT use `ngStyle`, use `style` bindings instead, for context: https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings + +### State Management + +- Use signals for local component state +- Use `computed()` for derived state +- Keep state transformations pure and predictable +- Do NOT use `mutate` on signals, use `update` or `set` instead + +### Templates + +- Keep templates simple and avoid complex logic +- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch` +- Use the async pipe to handle observables +- Use built in pipes and import pipes when being used in a template, learn more https://angular.dev/guide/templates/pipes# + +### Services + +- Design services around a single responsibility +- Use the `providedIn: 'root'` option for singleton services +- Use the `inject()` function instead of constructor injection diff --git a/README.md b/README.md index 2267fff..f4b0260 100644 --- a/README.md +++ b/README.md @@ -41,4 +41,4 @@ Live Demo with all current modules at https://fullstack-devops.github.io/ngx-mat - [Theme Switcher](https://github.com/fullstack-devops/ngx-mat-components/blob/main/docs/fs-theme-switcher.md) - [Calendar](https://github.com/fullstack-devops/ngx-mat-components/blob/main/docs/fs-calendar.md) -> Note: Sometimes I cannot document everything in the `docs/`, so please check the `workspace application` and source code for more information. Or sumbit an [Issue](https://github.com/fullstack-devops/ngx-mat-components/issues) \ No newline at end of file +> Note: Sometimes I cannot document everything in the `docs/`, so please check the `workspace application` and source code for more information. Or sumbit an [Issue](https://github.com/fullstack-devops/ngx-mat-components/issues) diff --git a/angular.json b/angular.json index 5232114..d871260 100644 --- a/angular.json +++ b/angular.json @@ -10,7 +10,7 @@ "prefix": "lib", "architect": { "build": { - "builder": "@angular-devkit/build-angular:ng-packagr", + "builder": "@angular/build:ng-packagr", "options": { "tsConfig": "projects/ngx-mat-components/tsconfig.lib.json", "project": "projects/ngx-mat-components/ng-package.json" @@ -26,7 +26,7 @@ "defaultConfiguration": "production" }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@angular/build:karma", "options": { "main": "projects/ngx-mat-components/src/test.ts", "tsConfig": "projects/ngx-mat-components/tsconfig.spec.json", @@ -50,7 +50,7 @@ "prefix": "app", "architect": { "build": { - "builder": "@angular-devkit/build-angular:application", + "builder": "@angular/build:application", "options": { "outputPath": { "base": "dist/lib-workspace" @@ -96,7 +96,7 @@ "defaultConfiguration": "production" }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@angular/build:dev-server", "configurations": { "production": { "buildTarget": "lib-workspace:build:production" @@ -108,13 +108,13 @@ "defaultConfiguration": "development" }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", + "builder": "@angular/build:extract-i18n", "options": { "buildTarget": "lib-workspace:build" } }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@angular/build:karma", "options": { "main": "projects/lib-workspace/src/test.ts", "polyfills": "projects/lib-workspace/src/polyfills.ts", @@ -138,6 +138,30 @@ }, "@angular-eslint/schematics:library": { "setParserOptionsProject": true + }, + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." } } } diff --git a/docs/calendar.md b/docs/calendar.md index 008c6dd..b917c04 100644 --- a/docs/calendar.md +++ b/docs/calendar.md @@ -1,167 +1,543 @@ -# FsCalendarModule Documentation +# FsCalendar Documentation -The `FsCalendarModule` provides advanced, flexible calendar components for Angular Material applications. It includes both calendar panels and table views, supporting custom data, range selection, and Material 3 theming. +The `FsCalendar` components provide flexible, modern calendar views for Angular Material applications. Available in two variants: panels-based and table-based layouts. + +> **Angular 20+**: These components use standalone architecture, signals-based reactivity, and OnPush change detection for optimal performance. --- ## Features -- **Calendar Panels** for monthly or annual views -- **Calendar Table** for tabular, multi-entry calendar data -- **Range and single date selection** support -- **Custom day rendering** (colors, tooltips, badges, etc.) -- **Material 3 theming** and accessibility -- **Highly configurable** via inputs +- **Two layout variants**: Panels (card-based) and Table (grid-based) +- **Multi-select support**: Select multiple dates with visual feedback +- **Status indicators**: Visual day status with customizable colors +- **Signal-based state**: Efficient reactivity with Angular signals +- **Accessibility**: Full ARIA support with keyboard navigation +- **Material 3 theming**: Seamless integration with Angular Material themes +- **Responsive design**: Adapts to different screen sizes --- -## Module Import - -```ts -import { FsCalendarModule } from '@fullstack-devops/ngx-mat-components'; +## Installation + +### Standalone Components (Angular 20+) + +Import individual components directly: + +```typescript +import { + FsCalendarPanelsComponent, + FsCalendarTableComponent, + CalendarDay, + DayStatus +} from '@fullstack-devops/ngx-mat-components'; + +@Component({ + selector: 'app-root', + imports: [ + FsCalendarPanelsComponent, + // OR + FsCalendarTableComponent, + ] +}) +export class AppComponent {} ``` --- -## Main Components & Directives +## Main Components -- [`FsCalendarPanelsComponent`](../projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts) -- [`FsCalendarTableComponent`](../projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts) -- [`FsCalendarTableNameDirective`](../projects/ngx-mat-components/src/fs-calendar/directives/fs-calendar-table-name.directive.ts) +- [`FsCalendarPanelsComponent`](../projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts) - Card-based calendar layout +- [`FsCalendarTableComponent`](../projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts) - Table/grid-based calendar layout --- -## Usage Examples - -### Calendar Panels +## Basic Usage + +### Panels Layout + +```typescript +import { Component, signal } from '@angular/core'; +import { + FsCalendarPanelsComponent, + CalendarDay +} from '@fullstack-devops/ngx-mat-components'; + +@Component({ + selector: 'app-calendar', + imports: [FsCalendarPanelsComponent], + template: ` + + ` +}) +export class CalendarComponent { + year = signal(2024); + month = signal(3); // April (0-indexed) + selectedDates = signal([]); + days = signal([]); + + onDateSelected(date: Date) { + this.selectedDates.update(dates => [...dates, date]); + } + + onMonthChanged(change: { year: number; month: number }) { + this.year.set(change.year); + this.month.set(change.month); + } +} +``` -```html - - +### Table Layout + +```typescript +import { Component, signal } from '@angular/core'; +import { + FsCalendarTableComponent, + CalendarDay, + DayStatus +} from '@fullstack-devops/ngx-mat-components'; + +@Component({ + selector: 'app-calendar-table', + imports: [FsCalendarTableComponent], + template: ` + + ` +}) +export class CalendarTableComponent { + year = signal(2024); + month = signal(3); + selectedDates = signal([]); + + // Example with day statuses + days = signal([ + { + date: new Date(2024, 3, 15), + status: 'available', + statusColor: '#4caf50' + }, + { + date: new Date(2024, 3, 20), + status: 'booked', + statusColor: '#f44336' + } + ]); + + onDateSelected(date: Date) { + const dates = this.selectedDates(); + const index = dates.findIndex(d => + d.getTime() === date.getTime() + ); + + if (index >= 0) { + // Deselect + this.selectedDates.update(dates => + dates.filter((_, i) => i !== index) + ); + } else { + // Select + this.selectedDates.update(dates => [...dates, date]); + } + } + + onMonthChanged(change: { year: number; month: number }) { + this.year.set(change.year); + this.month.set(change.month); + // Load data for new month + this.loadDaysForMonth(change.year, change.month); + } + + private loadDaysForMonth(year: number, month: number) { + // Fetch or compute days with statuses + } +} ``` -### Calendar Table +--- -```html - - Persons - +## API + +### Inputs (Component Signals) + +Both `FsCalendarPanelsComponent` and `FsCalendarTableComponent` accept: + +```typescript +// Required inputs +year: InputSignal // Current year +month: InputSignal // Current month (0-11) +selectedDates: InputSignal // Selected dates array +days: InputSignal // Days with status info ``` ---- +### Outputs (Component Outputs) -## Configuration +```typescript +dateSelected: OutputEmitterRef // Emits when date clicked +monthChanged: OutputEmitterRef<{ year: number; month: number }> // Emits on month navigation +``` -### Calendar Panels Data +### Types -[`CalendarPanels`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts): +#### CalendarDay -```ts -export interface CalendarPanels { - config: CalendarPanelsConfig; - data: CalendarExtendedDay[]; +```typescript +export interface CalendarDay { + date: Date; // The date + status?: DayStatus; // Optional status + statusColor?: string; // Optional color (hex/rgb) } ``` -#### Example Config - -[`CalendarPanelsConfig`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts): - -```ts -export interface CalendarPanelsConfig { - renderMode: 'monthly' | 'annual'; - selectMode: 'click' | 'range'; - calendarWeek: boolean; - displayYear?: boolean; - switches?: boolean; - bluredDays?: boolean; - markWeekend?: boolean; - firstDayOfWeekMonday?: boolean; - panelWidth?: string; -} +#### DayStatus + +```typescript +export type DayStatus = + | 'available' + | 'booked' + | 'pending' + | 'blocked' + | string; // Custom status ``` -#### Example Data +--- + +## Advanced Usage -[`CalendarExtendedDay`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts): +### Multi-Select Calendar with Status -```ts -export interface CalendarExtendedDay { +```typescript +import { Component, signal, computed } from '@angular/core'; +import { + FsCalendarPanelsComponent, + CalendarDay, + DayStatus +} from '@fullstack-devops/ngx-mat-components'; + +interface Booking { date: Date; - char?: string; - colors?: { - backgroundColor: string; - color?: string; - }; - toolTip?: string; - badge?: { - badgeMode: 'int' | 'icon'; - badgeInt?: number; - badgeIcon?: string; - }; - _meta?: CalendarExtendedDayMeta; - customData?: T; + status: DayStatus; } -``` -### Calendar Table Data - -[`CalendarTableEntry`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts): +@Component({ + selector: 'app-booking-calendar', + imports: [FsCalendarPanelsComponent], + template: ` +
+
+

Selected Dates: {{ selectedCount() }}

+ +
+ + + +
+ Available + Booked + Selected +
+
+ `, + styles: [` + .booking-calendar { + max-width: 1200px; + margin: 0 auto; + } + + .summary { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + } + + .legend { + display: flex; + gap: 1rem; + margin-top: 1rem; + } + + .legend-item { + display: inline-flex; + align-items: center; + gap: 0.5rem; + + &::before { + content: ''; + width: 16px; + height: 16px; + border-radius: 4px; + } + + &.available::before { background: #4caf50; } + &.booked::before { background: #f44336; } + &.selected::before { background: #2196f3; } + } + `] +}) +export class BookingCalendarComponent { + year = signal(new Date().getFullYear()); + month = signal(new Date().getMonth()); + selectedDates = signal([]); + + // Example bookings data + bookings = signal([ + { date: new Date(2024, 3, 10), status: 'booked' }, + { date: new Date(2024, 3, 15), status: 'booked' }, + { date: new Date(2024, 3, 20), status: 'available' }, + { date: new Date(2024, 3, 25), status: 'available' }, + ]); + + // Computed calendar days + calendarDays = computed(() => { + return this.bookings().map(booking => ({ + date: booking.date, + status: booking.status, + statusColor: this.getStatusColor(booking.status) + })); + }); + + selectedCount = computed(() => this.selectedDates().length); + + toggleDate(date: Date) { + const dates = this.selectedDates(); + const index = dates.findIndex(d => + d.toDateString() === date.toDateString() + ); + + if (index >= 0) { + this.selectedDates.update(dates => + dates.filter((_, i) => i !== index) + ); + } else { + // Check if date is available + const booking = this.bookings().find(b => + b.date.toDateString() === date.toDateString() + ); + + if (booking?.status === 'available') { + this.selectedDates.update(dates => [...dates, date]); + } + } + } + + clearSelection() { + this.selectedDates.set([]); + } + + onMonthChanged(change: { year: number; month: number }) { + this.year.set(change.year); + this.month.set(change.month); + // Load bookings for new month + } + + private getStatusColor(status: DayStatus): string { + switch (status) { + case 'available': return '#4caf50'; + case 'booked': return '#f44336'; + case 'pending': return '#ff9800'; + case 'blocked': return '#9e9e9e'; + default: return '#2196f3'; + } + } +} +``` -```ts -export interface CalendarTableEntry { - name: string; - data: CalendarExtendedDay[]; +### Integration with Backend API + +```typescript +import { Component, signal, effect } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { + FsCalendarTableComponent, + CalendarDay +} from '@fullstack-devops/ngx-mat-components'; + +@Component({ + selector: 'app-api-calendar', + imports: [FsCalendarTableComponent], + template: ` + + + @if (loading()) { +
Loading...
+ } + ` +}) +export class ApiCalendarComponent { + private http = inject(HttpClient); + + year = signal(new Date().getFullYear()); + month = signal(new Date().getMonth()); + selectedDates = signal([]); + days = signal([]); + loading = signal(false); + + constructor() { + // Load data when month/year changes + effect(() => { + const year = this.year(); + const month = this.month(); + this.loadAvailability(year, month); + }); + } + + private async loadAvailability(year: number, month: number) { + this.loading.set(true); + + try { + const response = await firstValueFrom( + this.http.get<{ availability: CalendarDay[] }>( + `/api/calendar/${year}/${month}` + ) + ); + + this.days.set(response.availability); + } catch (error) { + console.error('Failed to load availability:', error); + } finally { + this.loading.set(false); + } + } + + async onDateSelected(date: Date) { + // Optimistic update + this.selectedDates.update(dates => [...dates, date]); + + try { + await firstValueFrom( + this.http.post('/api/bookings', { date }) + ); + } catch (error) { + // Rollback on error + this.selectedDates.update(dates => + dates.filter(d => d.getTime() !== date.getTime()) + ); + console.error('Booking failed:', error); + } + } + + onMonthChanged(change: { year: number; month: number }) { + this.year.set(change.year); + this.month.set(change.month); + } } ``` --- -## Selection Events +## Accessibility -- The panels emit a `(selection)` event with a [`CalendarEvent`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts): - - Range selection: `{ type: 'range', start, end, affectedDays }` - - Single date: `{ type: 'click', date }` +Both calendar components include: + +- **Keyboard navigation**: Arrow keys to navigate dates, Enter/Space to select +- **ARIA labels**: Screen reader support for dates, months, and status +- **Focus management**: Proper focus indicators and tab order +- **Status announcements**: Screen readers announce day status + +Example accessible usage: + +```html + +``` --- ## Theming & Styling -- The module supports Material 3 theming. -- SCSS mixin: `fs-calendar-theme` ([styles/fs-calendar/_theming.scss](../projects/ngx-mat-components/styles/fs-calendar/_theming.scss)) -- To use the theme in your styles: - ```scss - @use '@fullstack-devops/ngx-mat-components' as fsc; - @include fsc.core(); - ``` +### Material 3 Theme Integration + +```scss +@use '@fullstack-devops/ngx-mat-components' as fsc; +@use '@angular/material' as mat; + +@include mat.app-background(); +@include fsc.core(); +``` + +### Custom Status Colors + +```typescript +// Define custom status colors +const statusColors: Record = { + available: '#4caf50', + booked: '#f44336', + pending: '#ff9800', + blocked: '#757575', + confirmed: '#2196f3' +}; + +days.set( + availabilityData.map(item => ({ + date: item.date, + status: item.status, + statusColor: statusColors[item.status] + })) +); +``` --- -## API Reference +## Panels vs Table: When to Use -- [`FsCalendarPanelsComponent`](../projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts) -- [`FsCalendarTableComponent`](../projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts) -- [`FsCalendarTableNameDirective`](../projects/ngx-mat-components/src/fs-calendar/directives/fs-calendar-table-name.directive.ts) -- [`CalendarPanels`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts) -- [`CalendarPanelsConfig`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts) -- [`CalendarExtendedDay`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts) -- [`CalendarTableEntry`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts) -- [`CalendarEvent`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts) -- [`FsCalendarService`](../projects/ngx-mat-components/src/fs-calendar/services/fs-calendar.service.ts) +### Use Panels Layout When: +- Card-based UI fits your design +- You want larger, more prominent day cells +- Displaying rich content per day (multiple events, images) +- Mobile-first responsive design + +### Use Table Layout When: +- Traditional calendar grid view is preferred +- Compact display is needed +- Printing calendar views +- Dense data display (month overview) --- -## Example Screenshots +## API Reference + +### Components +- [`FsCalendarPanelsComponent`](../projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts) +- [`FsCalendarTableComponent`](../projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts) -![Calendar Panels Example](../projects/lib-workspace/src/assets/calendar-panels-shot.png) -![Calendar Table Example](../projects/lib-workspace/src/assets/calendar-table-shot.png) +### Types +- [`CalendarDay`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts) +- [`DayStatus`](../projects/ngx-mat-components/src/fs-calendar/calendar.models.ts) --- @@ -169,5 +545,6 @@ export interface CalendarTableEntry { - [Live Demo](https://fullstack-devops.github.io/ngx-mat-components) - Workspace Examples: - - [showcase-calendar-panels](https://github.com/fullstack-devops/ngx-mat-components/tree/main/projects/lib-workspace/src/app/content/showcase-calendar-panels) - - [showcase-calendar-table](https://github.com/fullstack-devops/ngx-mat-components/tree/main/projects/lib-workspace/src/app/content/showcase-calendar-table) \ No newline at end of file + - [Panels Example](https://github.com/fullstack-devops/ngx-mat-components/blob/main/projects/lib-workspace/src/app/showcase-calendar-panels/showcase-calendar-panels.component.ts) + - [Table Example](https://github.com/fullstack-devops/ngx-mat-components/blob/main/projects/lib-workspace/src/app/showcase-calendar-table/showcase-calendar-table.component.ts) +- [CHANGELOG.md](../CHANGELOG.md) - Migration guide from NgModules diff --git a/docs/fs-nav-frame.md b/docs/fs-nav-frame.md index 8bfd256..b67e532 100644 --- a/docs/fs-nav-frame.md +++ b/docs/fs-nav-frame.md @@ -1,6 +1,8 @@ -# FsNavFrameModule Documentation +# FsNavFrame Documentation -The `FsNavFrameModule` provides a flexible, modern navigation frame for Angular Material applications. It includes a responsive sidebar, toolbar, and user profile area, supporting content projection and theming. +The `FsNavFrame` provides a flexible, modern navigation frame for Angular Material applications. It includes a responsive sidebar, toolbar, and user profile area, supporting content projection and theming. + +> **Angular 20+**: This component uses standalone architecture, signals-based reactivity, and OnPush change detection for optimal performance. --- @@ -12,70 +14,165 @@ The `FsNavFrameModule` provides a flexible, modern navigation frame for Angular - **Configurable sizing** for toolbar and sidebar - **Content projection** for full layout flexibility - **Material 3 theming** support +- **Signal-based reactivity** for efficient change detection +- **Accessibility** - Full ARIA support with keyboard navigation --- -## Module Import - -```ts -import { FsNavFrameModule } from '@fullstack-devops/ngx-mat-components'; +## Installation + +### Standalone Components (Angular 20+) + +Import individual components directly: + +```typescript +import { + FsNavFrameComponent, + FsNavFrameSidebar, + FsNavFrameSidebarItemComponent, + FsNavUserProfileComponent, + FsNavUserProfileActionsDirective, + FsNavFrameToolbarComponent, + FsNavFrameToolbarStartDirective, + FsNavFrameToolbarCenterDirective, + FsNavFrameToolbarEndDirective, + FsNavFrameContentDirective, + NavFrameConfig, + NavFrameSizing, + NavRoutes +} from '@fullstack-devops/ngx-mat-components'; + +@Component({ + selector: 'app-root', + imports: [ + FsNavFrameComponent, + FsNavFrameSidebar, + FsNavFrameSidebarItemComponent, + FsNavUserProfileComponent, + FsNavUserProfileActionsDirective, + FsNavFrameToolbarComponent, + FsNavFrameToolbarStartDirective, + FsNavFrameToolbarCenterDirective, + FsNavFrameToolbarEndDirective, + FsNavFrameContentDirective, + ] +}) +export class AppComponent {} ``` --- ## Main Components & Directives -- [`FsNavFrameComponent`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.ts) -- [`FsNavFrameToolbarComponent`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/fs-nav-frame-toolbar.component.ts) -- [`FsNavFrameSidebar`](../projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar.ts) -- [`FsNavFrameSidebarItemComponent`](../projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts) -- [`FsNavUserProfileComponent`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/fs-nav-user-profile.component.ts) -- [`FsNavFrameContentDirective`](../projects/ngx-mat-components/src/fs-nav-frame/directives/fs-nav-frame-content.directive.ts) -- Toolbar slot directives: - - [`FsNavFrameToolbarStartDirective`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/directives/fs-nav-frame-toolbar-start.directive.ts) - - [`FsNavFrameToolbarCenterDirective`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/directives/fs-nav-frame-toolbar-center.directive.ts) - - [`FsNavFrameToolbarEndDirective`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/directives/fs-nav-frame-toolbar-end.directive.ts) -- User profile slot directives: - - [`FsNavUserProfileNameDirective`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/directives/fs-nav-user-profile-name.directive.ts) - - [`FsNavUserProfileSubNameDirective`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/directives/fs-nav-user-profile-subname.directive.ts) - - [`FsNavUserProfileActionsDirective`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/directives/fs-nav-user-profile-actions.directive.ts) +- [`FsNavFrameComponent`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.ts) - Main navigation frame +- [`FsNavFrameToolbarComponent`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/fs-nav-frame-toolbar.component.ts) - Top toolbar +- [`FsNavFrameSidebar`](../projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar.ts) - Sidebar container +- [`FsNavFrameSidebarItemComponent`](../projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts) - Sidebar navigation items +- [`FsNavUserProfileComponent`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/fs-nav-user-profile.component.ts) - User profile section +- [`FsNavFrameContentDirective`](../projects/ngx-mat-components/src/fs-nav-frame/directives/fs-nav-frame-content.directive.ts) - Main content area + +### Toolbar Slot Directives +- [`FsNavFrameToolbarStartDirective`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/directives/fs-nav-frame-toolbar-start.directive.ts) - Left side content +- [`FsNavFrameToolbarCenterDirective`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/directives/fs-nav-frame-toolbar-center.directive.ts) - Center content +- [`FsNavFrameToolbarEndDirective`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/directives/fs-nav-frame-toolbar-end.directive.ts) - Right side content + +### User Profile Slot Directives +- [`FsNavUserProfileNameDirective`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/directives/fs-nav-user-profile-name.directive.ts) - Profile name +- [`FsNavUserProfileSubNameDirective`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/directives/fs-nav-user-profile-subname.directive.ts) - Profile subname +- [`FsNavUserProfileActionsDirective`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/directives/fs-nav-user-profile-actions.directive.ts) - Profile action buttons --- -## Usage Example - -```html - - - App Title - - - - - - - - - - - - Home - - - - - - Jane Doe - Engineer - - - - - - - - - +## Basic Usage + +```typescript +import { Component, signal } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { + FsNavFrameComponent, + FsNavFrameSidebar, + FsNavFrameSidebarItemComponent, + FsNavUserProfileComponent, + FsNavFrameToolbarComponent, + FsNavFrameToolbarStartDirective, + FsNavFrameContentDirective, + NavFrameConfig, + NavFrameSizing +} from '@fullstack-devops/ngx-mat-components'; +import { HouseIcon, SettingsIcon } from 'lucide-angular'; + +@Component({ + selector: 'app-root', + imports: [ + RouterOutlet, + FsNavFrameComponent, + FsNavFrameSidebar, + FsNavFrameSidebarItemComponent, + FsNavUserProfileComponent, + FsNavFrameToolbarComponent, + FsNavFrameToolbarStartDirective, + FsNavFrameContentDirective, + ], + template: ` + + + My App + + + + + + Home + + + + Settings + + + + + @if (currentUser(); as user) { + {{ user.name }} + } + + + + + + + + + + ` +}) +export class AppComponent { + readonly HouseIcon = HouseIcon; + readonly SettingsIcon = SettingsIcon; + + navFrameConfig: NavFrameConfig = { + appName: 'My Application', + appVersion: '1.0.0', + logoSrc: '/assets/logo.png' // Optional + }; + + sizing: NavFrameSizing = { + toolbarHeight: 3, // rem units + sidebarWidthClosed: 4, // rem units + sidebarWidthOpened: 18 // rem units + }; + + // Signal-based user state + currentUser = signal({ + name: 'John Doe', + email: 'john@example.com' + }); +} ``` --- @@ -84,22 +181,22 @@ import { FsNavFrameModule } from '@fullstack-devops/ngx-mat-components'; ### `navFrameConfig` ([`NavFrameConfig`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.modules.ts)) -```ts +```typescript export interface NavFrameConfig { - appName?: string; // Displayed app name (opened mode) - appVersion?: string; // Optional app version - logoSrc?: string; // Optional logo URL + appName?: string; // Displayed app name (opened mode) + appVersion?: string; // Optional app version + logoSrc?: string; // Optional logo URL sizing?: NavFrameSizing; // Optional sizing config } ``` ### `sizing` ([`NavFrameSizing`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.modules.ts)) -```ts +```typescript export interface NavFrameSizing { - toolbarHeight?: number; // Toolbar height in rem (default: 3) - sidebarWidthClosed?: number; // Sidebar width (closed) in rem (default: 4) - sidebarWidthOpened?: number; // Sidebar width (opened) in rem (default: 18) + toolbarHeight?: number; // Toolbar height in rem (default: 3) + sidebarWidthClosed?: number; // Sidebar width (closed) in rem (default: 4) + sidebarWidthOpened?: number; // Sidebar width (opened) in rem (default: 18) } ``` @@ -107,51 +204,305 @@ export interface NavFrameSizing { ## Sidebar Navigation -- Use `` to define the sidebar. -- Add `` for each navigation entry. -- Use `[routerLink]` for Angular routing. -- You can project icons and labels. +### Basic Sidebar Items + +```typescript + + + + Dashboard + + + + + Users + + +``` + +### Signal-Based Dynamic Navigation + +```typescript +@Component({ + template: ` + + @for (route of routes(); track route.title) { + + + {{ route.title }} + + } + + ` +}) +export class AppComponent { + routes = signal([ + { path: '/home', title: 'Home', icon: HouseIcon }, + { path: '/settings', title: 'Settings', icon: SettingsIcon } + ]); +} +``` + +### Accessibility + +The sidebar items automatically provide: +- **ARIA labels** via the `label` input (required for screen readers) +- **Keyboard navigation** with Tab/Enter +- **Router link active states** for current page indication + +```typescript +// Good: Accessible sidebar item + + + Home + +``` --- ## Toolbar -- Use `` to define the toolbar. -- Slot content using ``, ``, and `` for flexible layouts. +### Toolbar Layout Slots + +```typescript + + + + Logo + My App + + + + + + + + + + + + + +``` + +### Signal-Based Toolbar State + +```typescript +@Component({ + template: ` + + + {{ appTitle() }} + + + + @if (hasNotifications()) { + + } + + + ` +}) +export class AppComponent { + appTitle = signal('My Application'); + notificationCount = signal(3); + hasNotifications = computed(() => this.notificationCount() > 0); + + showNotifications() { + // Handle notification display + } +} +``` --- ## User Profile -- Use `` for the user section. -- Project name, subname, and actions using: - - `` - - `` - - `` +### Basic User Profile + +```typescript + + + {{ currentUser().name }} + + + + {{ currentUser().role }} + + + + + + +``` + +### Signal-Based User Management + +```typescript +@Component({ + template: ` + + @if (user(); as currentUser) { + + {{ currentUser.displayName }} + + + + {{ currentUser.email }} + + + + + + } + + + + + + + ` +}) +export class AppComponent { + user = signal({ + displayName: 'Jane Doe', + email: 'jane@example.com', + avatarUrl: '/assets/avatar.jpg' + }); + + profilePicture = computed(() => this.user()?.avatarUrl ?? ''); + + editProfile() { + // Navigate to profile edit + } + + logout() { + this.user.set(null); + // Handle logout + } +} +``` --- ## Theming & Styling -- The module supports Material 3 theming. -- SCSS mixin: `fs-nav-frame-theme` ([styles/fs-nav-frame/_theming.scss](../projects/ngx-mat-components/styles/fs-nav-frame/_theming.scss)) -- To use the theme in your styles: - ```scss - @use '@fullstack-devops/ngx-mat-components' as fsc; - - @include mat.app-background(); +The nav frame supports Material 3 theming out of the box. + +### SCSS Mixin + +Use the provided theme mixin in your styles: + +```scss +@use '@fullstack-devops/ngx-mat-components' as fsc; +@use '@angular/material' as mat; + +@include mat.app-background(); +@include fsc.core(); +``` + +### Custom CSS Variables + +Override default sizing with CSS custom properties: + +```css +:root { + --fs-nav-frame-toolbar-height: 4rem; + --fs-nav-frame-sidebar-width-closed: 3.5rem; + --fs-nav-frame-sidebar-width-opened: 16rem; +} +``` + +--- + +## Advanced Usage + +### Programmatic Sidebar Control + +```typescript +import { FsNavFrameService } from '@fullstack-devops/ngx-mat-components'; + +@Component({ + template: ` + + + + + + + + + + ` +}) +export class AppComponent { + private navFrameService = inject(FsNavFrameService); - @include fsc.core(); - ``` + toggleSidebar() { + this.navFrameService.toggleOpened(); + } +} +``` + +### Responsive Behavior + +The sidebar automatically: +- Closes on mobile devices (< 768px) +- Opens on desktop devices (>= 768px) +- Adjusts based on `NavFrameSizing` configuration --- ## API Reference +### Components - [`FsNavFrameComponent`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.ts) -- [`FsNavFrameModule`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.module.ts) +- [`FsNavFrameToolbarComponent`](../projects/ngx-mat-components/src/fs-nav-frame/nav-frame-toolbar/fs-nav-frame-toolbar.component.ts) +- [`FsNavUserProfileComponent`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-user-profile/fs-nav-user-profile.component.ts) +- [`FsNavFrameSidebarItemComponent`](../projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts) + +### Interfaces - [`NavFrameConfig`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.modules.ts) - [`NavFrameSizing`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.modules.ts) +- [`NavRoutes`](../projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.modules.ts) + +### Services +- [`FsNavFrameService`](../projects/ngx-mat-components/src/fs-nav-frame/services/fs-nav-frame.service.ts) --- @@ -167,3 +518,4 @@ export interface NavFrameSizing { - Workspace Example: - [app.html](https://github.com/fullstack-devops/ngx-mat-components/blob/main/projects/lib-workspace/src/app/app.html) - [app.ts](https://github.com/fullstack-devops/ngx-mat-components/blob/main/projects/lib-workspace/src/app/app.ts) +- [CHANGELOG.md](../CHANGELOG.md) - Migration guide from NgModules diff --git a/docs/theme-menu.md b/docs/theme-menu.md index eb34ef1..098618a 100644 --- a/docs/theme-menu.md +++ b/docs/theme-menu.md @@ -1,136 +1,402 @@ # FsThemeMenu Documentation -The `FsThemeMenu` component provides a user-friendly theme switcher for Angular Material applications, allowing users to toggle between light, dark, and system (auto) color schemes. +The `FsThemeMenu` provides a user-friendly theme switcher for Angular Material applications, allowing users to toggle between light and dark themes. + +> **Angular 20+**: This component uses standalone architecture, signals-based reactivity, and OnPush change detection for optimal performance. --- ## Features -- **Switch between Light, Dark, and System themes** -- **Material 3 theming** support -- **Accessible menu** with clear icons and labels -- **Easy integration** into toolbars or navigation frames -- **Persistent theme selection** using `localStorage` (see `localStorageKey` input) +- **Light/Dark theme toggle** with smooth transitions +- **Persistent theme storage** using localStorage +- **System preference detection** - Auto-detects user's OS theme preference +- **Signal-based state** for efficient reactivity +- **Accessibility** - Full ARIA support with keyboard navigation +- **Material 3 theming** - Seamless integration with Angular Material +- **Custom icons** - Lucide icons for modern look --- -## Module Import +## Installation + +### Standalone Component (Angular 20+) + +Import the component directly: + +```typescript +import { FsThemeMenuComponent } from '@fullstack-devops/ngx-mat-components'; -```ts -import { FsThemeMenu } from '@fullstack-devops/ngx-mat-components'; +@Component({ + selector: 'app-root', + imports: [FsThemeMenuComponent] +}) +export class AppComponent {} ``` --- -## Main Component +## Component -- [`FsThemeMenu`](../projects/ngx-mat-components/src/fs-theme-menu/fs-theme-menu.ts) +- [`FsThemeMenuComponent`](../projects/ngx-mat-components/src/fs-theme-menu/fs-theme-menu.component.ts) - Theme switcher menu --- -## Usage Example - -```html - - - +## Basic Usage + +```typescript +import { Component } from '@angular/core'; +import { FsThemeMenuComponent } from '@fullstack-devops/ngx-mat-components'; + +@Component({ + selector: 'app-root', + imports: [FsThemeMenuComponent], + template: ` +
+

My Application

+ +
+ ` +}) +export class AppComponent {} ``` -You can place `` inside your toolbar or anywhere in your layout. The component will display a button that opens a menu for selecting the color scheme. +--- + +## Usage in Navigation Frame + +```typescript +import { Component } from '@angular/core'; +import { + FsNavFrameComponent, + FsNavFrameToolbarComponent, + FsNavFrameToolbarStartDirective, + FsNavFrameToolbarEndDirective, + FsThemeMenuComponent +} from '@fullstack-devops/ngx-mat-components'; + +@Component({ + selector: 'app-root', + imports: [ + FsNavFrameComponent, + FsNavFrameToolbarComponent, + FsNavFrameToolbarStartDirective, + FsNavFrameToolbarEndDirective, + FsThemeMenuComponent, + ], + template: ` + + + + My App + + + + + + + + + + + ` +}) +export class AppComponent {} +``` --- -## Inputs & Outputs +## How It Works + +### Theme Detection Priority -- `@Input() theme: FsThemeColorSchemes` - Set the current theme (`'auto'`, `'light-mode'`, `'dark-mode'`). +1. **User preference** (from localStorage) - If user has previously selected a theme +2. **System preference** (from `prefers-color-scheme`) - If no user preference exists +3. **Default theme** - Falls back to light theme -- `@Input() localStorageKey: string` - (Optional) The key used for storing the selected theme in `localStorage`. Defaults to `'fs-selected-theme'`. Use this if you want to scope the theme preference to a specific part of your app or avoid conflicts with other components. +### Theme Persistence -- `@Output() themeChange: EventEmitter` - Emits when the user selects a new theme. +The component automatically: +- Saves theme preference to `localStorage` with key `'theme'` +- Restores theme on app reload +- Applies theme changes to `document.documentElement.classList` + +### Theme Classes + +The component toggles these CSS classes on the `` element: +- `'light-theme'` - Light theme active +- `'dark-theme'` - Dark theme active --- -## Theme Enum +## Advanced Usage + +### Listening to Theme Changes + +```typescript +import { Component, effect } from '@angular/core'; +import { FsThemeMenuComponent } from '@fullstack-devops/ngx-mat-components'; +import { ThemeService } from './services/theme.service'; + +@Component({ + selector: 'app-root', + imports: [FsThemeMenuComponent], + template: ` + + ` +}) +export class AppComponent { + private themeService = inject(ThemeService); + + constructor() { + // React to theme changes + effect(() => { + const theme = this.getCurrentTheme(); + this.themeService.updateChartColors(theme); + this.updateFavicon(theme); + }); + } + + private getCurrentTheme(): 'light' | 'dark' { + return document.documentElement.classList.contains('dark-theme') + ? 'dark' + : 'light'; + } + + private updateFavicon(theme: 'light' | 'dark') { + const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement; + if (link) { + link.href = `/favicon-${theme}.ico`; + } + } +} +``` -[`FsThemeColorSchemes`](../projects/ngx-mat-components/src/fs-theme-menu/fs-theme-menu.ts): +### Custom Theme Service Integration + +```typescript +import { Injectable, signal, effect } from '@angular/core'; + +export type Theme = 'light' | 'dark'; + +@Injectable({ providedIn: 'root' }) +export class ThemeService { + // Centralized theme state + private _theme = signal('light'); + theme = this._theme.asReadonly(); + + constructor() { + // Initialize from localStorage + const stored = localStorage.getItem('theme') as Theme | null; + if (stored) { + this._theme.set(stored); + } else { + // Detect system preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + this._theme.set(prefersDark ? 'dark' : 'light'); + } + + // Apply theme changes + effect(() => { + const theme = this._theme(); + document.documentElement.classList.remove('light-theme', 'dark-theme'); + document.documentElement.classList.add(`${theme}-theme`); + localStorage.setItem('theme', theme); + }); + } + + toggleTheme() { + this._theme.update(current => current === 'light' ? 'dark' : 'light'); + } + + setTheme(theme: Theme) { + this._theme.set(theme); + } +} -```ts -export enum FsThemeColorSchemes { - Auto = 'auto', - Light = 'light-mode', - Dark = 'dark-mode', +// Usage in component +@Component({ + selector: 'app-root', + imports: [FsThemeMenuComponent], + template: ` +
+

Current Theme: {{ themeService.theme() }}

+ + + + @if (themeService.theme() === 'dark') { +

🌙 Dark mode active

+ } @else { +

☀️ Light mode active

+ } +
+ ` +}) +export class AppComponent { + themeService = inject(ThemeService); +} +``` + +### Programmatic Theme Control + +```typescript +import { Component, ViewChild } from '@angular/core'; +import { FsThemeMenuComponent } from '@fullstack-devops/ngx-mat-components'; + +@Component({ + selector: 'app-settings', + imports: [FsThemeMenuComponent], + template: ` +
+

Settings

+ +
+

Theme

+ + + +
+
+ ` +}) +export class SettingsComponent { + resetToSystemPreference() { + localStorage.removeItem('theme'); + + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const theme = prefersDark ? 'dark' : 'light'; + + document.documentElement.classList.remove('light-theme', 'dark-theme'); + document.documentElement.classList.add(`${theme}-theme`); + } } ``` --- -## How It Works +## Accessibility + +The theme menu includes: -- The selected theme is applied as a class (`light-mode`, `dark-mode`) to the ``. -- When `'auto'` is selected, the theme follows the user's system preference. -- The menu uses Material Design and includes icons for each theme option. -- Works only with Angular Material 3. -- The selected theme is persisted in `localStorage` under the key specified by `localStorageKey` (default: `'fs-selected-theme'`). Only explicit selections of `'light-mode'` or `'dark-mode'` are stored; selecting `'auto'` removes the key and follows the system preference. -- On initialization, the component reads the value from `localStorage` (if present) and applies it. -- Prepare the `style.scss` file in your project to include the theme styles. +- **ARIA label** - "Theme selection" for screen readers +- **Radio group semantics** - Proper `role="menuitemradio"` for theme options +- **Checked states** - `aria-checked` reflects current selection +- **Keyboard navigation** - Tab to focus, Enter/Space to toggle, Arrow keys to navigate options +- **Focus indicators** - Clear visual focus states + +Example with custom ARIA label: + +```html + +``` + +--- + +## Theming & Styling + +### Material 3 Theme Integration + +The component works seamlessly with Angular Material themes: ```scss @use '@angular/material' as mat; @use '@fullstack-devops/ngx-mat-components' as fsc; -@include fsc.core(); - -html { - color-scheme: light dark; // for system preference - @include mat.theme( - ( - color: mat.$green-palette, - typography: Roboto-local, - density: 0, - ) - ); + +// Define your light and dark themes +$light-theme: mat.define-theme(( + color: ( + theme-type: light, + primary: mat.$azure-palette, + ), +)); + +$dark-theme: mat.define-theme(( + color: ( + theme-type: dark, + primary: mat.$azure-palette, + ), +)); + +// Apply themes based on CSS class +:root { + @include mat.all-component-themes($light-theme); + @include fsc.core(); +} + +.dark-theme { + @include mat.all-component-colors($dark-theme); +} +``` + +### Custom Theme Styles + +```scss +// Example: Custom styles per theme +:root { + --background-color: #ffffff; + --text-color: #000000; + --card-background: #f5f5f5; } -body.dark-mode { - color-scheme: dark; + +.dark-theme { + --background-color: #121212; + --text-color: #ffffff; + --card-background: #1e1e1e; +} + +body { + background-color: var(--background-color); + color: var(--text-color); } -body.light-mode { - color-scheme: light; + +.card { + background-color: var(--card-background); } ``` --- -## Theming & Styling +## Icons -- The component supports Material 3 theming. -- SCSS: [`fs-theme-menu.scss`](../projects/ngx-mat-components/src/fs-theme-menu/fs-theme-menu.scss) -- To use the theme in your styles: - ```scss - @use '@fullstack-devops/ngx-mat-components' as fsc; - @include fsc.core(); - ``` +The component uses [Lucide Angular](https://lucide.dev/) icons: +- **Sun icon** (☀️) - Light theme +- **Moon icon** (🌙) - Dark theme + +The icons automatically change based on the current theme. --- ## API Reference -- [`FsThemeMenu`](../projects/ngx-mat-components/src/fs-theme-menu/fs-theme-menu.ts) -- [`FsThemeColorSchemes`](../projects/ngx-mat-components/src/fs-theme-menu/fs-theme-menu.ts) +### Component +- [`FsThemeMenuComponent`](../projects/ngx-mat-components/src/fs-theme-menu/fs-theme-menu.component.ts) ---- +### Public API + +```typescript +// No inputs or outputs - fully self-contained + +// Theme is stored in localStorage with key 'theme' +localStorage.getItem('theme') // 'light' | 'dark' -## Example Screenshot +// Theme class is applied to document root +document.documentElement.classList // 'light-theme' | 'dark-theme' +``` + +--- -> **Note:** The colors shown in the theme icon reflect your currently selected theme, providing a visual preview that matches your actual color scheme. +## Browser Support -![Theme Menu Example](./assets/theme-menu-shot.png) +- **localStorage** - Required for theme persistence +- **prefers-color-scheme** - Used for system theme detection (gracefully degrades) +- Works in all modern browsers (Chrome, Firefox, Safari, Edge) --- ## See Also - [Live Demo](https://fullstack-devops.github.io/ngx-mat-components) -- Workspace Example: - - [app.html](https://github.com/fullstack-devops/ngx-mat-components/blob/main/projects/lib-workspace/src/app/app.html) +- [Material 3 Theming Guide](https://material.angular.io/guide/theming) +- [CHANGELOG.md](../CHANGELOG.md) - Migration guide from NgModules diff --git a/package.json b/package.json index dd3606b..817ee7c 100644 --- a/package.json +++ b/package.json @@ -23,52 +23,52 @@ }, "private": false, "dependencies": { - "@angular/animations": "^19.2.14", - "@angular/cdk": "^19.2.19", - "@angular/common": "^19.2.14", - "@angular/compiler": "^19.2.14", - "@angular/core": "^19.2.14", - "@angular/forms": "^19.2.14", - "@angular/material": "^19.2.19", - "@angular/material-date-fns-adapter": "^19.2.19", - "@angular/platform-browser": "^19.2.14", - "@angular/platform-browser-dynamic": "^19.2.14", - "@angular/router": "^19.2.14", + "@angular/animations": "^20.3.6", + "@angular/cdk": "^20.2.9", + "@angular/common": "^20.3.6", + "@angular/compiler": "^20.3.6", + "@angular/core": "^20.3.6", + "@angular/forms": "^20.3.6", + "@angular/material": "^20.2.9", + "@angular/material-date-fns-adapter": "^20.2.9", + "@angular/platform-browser": "^20.3.6", + "@angular/platform-browser-dynamic": "^20.3.6", + "@angular/router": "^20.3.6", "date-fns": "^4.1.0", - "lucide-angular": "^0.523.0", + "lucide-angular": "^0.546.0", "postcss": "^8.5.6", "rxjs": "^7.8.2", "tslib": "^2.8.1", "zone.js": "^0.15.1" }, "devDependencies": { - "@angular-devkit/build-angular": "^19.2.15", - "@angular-eslint/builder": "^19.8.1", - "@angular-eslint/eslint-plugin": "^19.8.1", - "@angular-eslint/eslint-plugin-template": "^19.8.1", - "@angular-eslint/schematics": "^19.8.1", - "@angular-eslint/template-parser": "^19.8.1", - "@angular/cli": "^19.2.15", - "@angular/compiler-cli": "^19.2.14", - "@types/jasmine": "~5.1.8", - "@typescript-eslint/eslint-plugin": "^8.35.0", - "@typescript-eslint/parser": "^8.35.0", - "@typescript-eslint/types": "^8.35.0", - "@typescript-eslint/utils": "^8.35.0", + "@angular-eslint/builder": "^20.4.0", + "@angular-eslint/eslint-plugin": "^20.4.0", + "@angular-eslint/eslint-plugin-template": "^20.4.0", + "@angular-eslint/schematics": "^20.4.0", + "@angular-eslint/template-parser": "^20.4.0", + "@angular/build": "^20.3.6", + "@angular/cli": "^20.3.6", + "@angular/compiler-cli": "^20.3.6", + "@types/jasmine": "^5.1.12", + "@typescript-eslint/eslint-plugin": "^8.33.1", + "@typescript-eslint/parser": "^8.33.1", + "@typescript-eslint/types": "^8.46.1", + "@typescript-eslint/utils": "^8.33.1", "autoprefixer": "^10.4.21", - "eslint": "^9.29.0", - "jasmine-core": "~5.1.1", + "eslint": "^9.28.0", + "jasmine-core": "^5.12.0", "karma": "~6.4.4", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.1", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", - "ng-packagr": "^19.2.2", - "prettier": "^3.6.1", + "ng-packagr": "^20.3.0", + "prettier": "^3.6.2", "prettier-eslint": "^16.4.2", - "sass": "^1.89.2", - "typescript": "~5.8.3", - "vite": "^6.0.0", + "sass": "^1.93.2", + "typescript": "~5.9.3", + "vite": "^6.4.0", "yarn-audit-fix": "^10.1.1" } } diff --git a/projects/lib-workspace/karma.conf.js b/projects/lib-workspace/karma.conf.js index 0f97f95..041aaef 100644 --- a/projects/lib-workspace/karma.conf.js +++ b/projects/lib-workspace/karma.conf.js @@ -5,13 +5,7 @@ module.exports = function (config) { config.set({ basePath: '', frameworks: ['jasmine', '@angular-devkit/build-angular'], - plugins: [ - require('karma-jasmine'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-coverage'), - require('@angular-devkit/build-angular/plugins/karma'), - ], + plugins: [require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage')], client: { jasmine: { // you can add configuration options for Jasmine here diff --git a/projects/lib-workspace/src/app/app.html b/projects/lib-workspace/src/app/app.html index 22edf1a..889a65c 100644 --- a/projects/lib-workspace/src/app/app.html +++ b/projects/lib-workspace/src/app/app.html @@ -28,22 +28,25 @@
- - - {{ route.title }} - + @for (route of navRoutes; track route.title) { + + + {{ route.title }} + + } - - Some User - Enginer + @if (userProfile(); as user) { + {{ user.name }} + {{ user.subname }} + } - - diff --git a/projects/lib-workspace/src/app/app.ts b/projects/lib-workspace/src/app/app.ts index 59dfd7a..df7a0fc 100644 --- a/projects/lib-workspace/src/app/app.ts +++ b/projects/lib-workspace/src/app/app.ts @@ -1,5 +1,5 @@ import { version } from 'packageJson'; -import { Component, HostBinding } from '@angular/core'; +import { AfterViewInit, ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; @@ -27,7 +27,23 @@ import { CogIcon, PaintBucketIcon, } from 'lucide-angular'; -import { FsNavFrameModule, FsCalendarModule, NavFrameConfig, NavFrameSizing, NavRoutes, FsThemeMenu } from 'projects/ngx-mat-components/src/public-api'; +import { + FsNavFrameComponent, + FsNavFrameSidebar, + FsNavFrameSidebarItemComponent, + FsNavUserProfileComponent, + FsNavUserProfileActionsDirective, + FsNavFrameToolbarComponent, + FsNavFrameToolbarStartDirective, + FsNavFrameToolbarCenterDirective, + FsNavFrameToolbarEndDirective, + FsNavFrameContentDirective, + NavFrameConfig, + NavFrameSizing, + NavRoutes, + FsThemeMenu, +} from 'projects/ngx-mat-components/src/public-api'; +import { MockUserService } from './services/mockuser.service'; @Component({ selector: 'app-root', @@ -46,16 +62,28 @@ import { FsNavFrameModule, FsCalendarModule, NavFrameConfig, NavFrameSizing, Nav MatCardModule, MatSlideToggleModule, MatDialogModule, - /* Lib modules */ - FsNavFrameModule, - FsCalendarModule, + /* Lib components */ + FsNavFrameComponent, + FsNavFrameSidebar, + FsNavFrameSidebarItemComponent, + FsNavUserProfileComponent, + FsNavUserProfileActionsDirective, + FsNavFrameToolbarComponent, + FsNavFrameToolbarStartDirective, + FsNavFrameToolbarCenterDirective, + FsNavFrameToolbarEndDirective, + FsNavFrameContentDirective, FsThemeMenu, ], templateUrl: './app.html', styleUrl: './app.scss', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class App { +export class App implements AfterViewInit { title = 'FS DevOps`s ng mat components'; + + private readonly mockUserService = inject(MockUserService); + readonly CircleQuestionMarkIcon = CircleQuestionMarkIcon; readonly NewspaperIcon = NewspaperIcon; readonly BellIcon = BellIcon; @@ -64,6 +92,12 @@ export class App { readonly CogIcon = CogIcon; readonly PaintBucketIcon = PaintBucketIcon; + readonly userProfile = this.mockUserService.user; + + ngAfterViewInit(): void { + this.mockUserService.loadUser(); + } + @HostBinding('attr.app-version') appVersion = version; navFrameConfig: NavFrameConfig = { diff --git a/projects/lib-workspace/src/app/content/home/home.component.ts b/projects/lib-workspace/src/app/content/home/home.component.ts index 3ecbfc0..42e5d57 100644 --- a/projects/lib-workspace/src/app/content/home/home.component.ts +++ b/projects/lib-workspace/src/app/content/home/home.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; @Component({ @@ -7,6 +7,7 @@ import { MatCardModule } from '@angular/material/card'; imports: [CommonModule, MatCardModule], templateUrl: './home.component.html', styleUrls: ['./home.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomeComponent implements OnInit { constructor() {} diff --git a/projects/lib-workspace/src/app/content/showcase-calendar-panels/showcase-calendar-panels.component.html b/projects/lib-workspace/src/app/content/showcase-calendar-panels/showcase-calendar-panels.component.html index 82834b8..a3ceb8a 100644 --- a/projects/lib-workspace/src/app/content/showcase-calendar-panels/showcase-calendar-panels.component.html +++ b/projects/lib-workspace/src/app/content/showcase-calendar-panels/showcase-calendar-panels.component.html @@ -2,13 +2,17 @@ Months before - {{ num }} + @for (num of monthsAfterBefore; track num) { + {{ num }} + } Months after - {{ num }} + @for (num of monthsAfterBefore; track num) { + {{ num }} + }
diff --git a/projects/lib-workspace/src/app/content/showcase-calendar-panels/showcase-calendar-panels.component.ts b/projects/lib-workspace/src/app/content/showcase-calendar-panels/showcase-calendar-panels.component.ts index 9a32bcc..aa1c4ee 100644 --- a/projects/lib-workspace/src/app/content/showcase-calendar-panels/showcase-calendar-panels.component.ts +++ b/projects/lib-workspace/src/app/content/showcase-calendar-panels/showcase-calendar-panels.component.ts @@ -1,10 +1,16 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { CalendarEvent, CalendarExtendedDay, CalendarPanels, CalendarPanelsConfig, FsCalendarModule } from 'projects/ngx-mat-components/src/public-api'; +import { + CalendarEvent, + CalendarExtendedDay, + CalendarPanels, + CalendarPanelsConfig, + FsCalendarPanelsComponent, +} from 'projects/ngx-mat-components/src/public-api'; interface CustomTestObj { id: number; @@ -13,9 +19,10 @@ interface CustomTestObj { @Component({ selector: 'app-showcase-calendar-panels', - imports: [CommonModule, FormsModule, FsCalendarModule, MatSlideToggleModule, MatFormFieldModule, MatSelectModule], + imports: [CommonModule, FormsModule, FsCalendarPanelsComponent, MatSlideToggleModule, MatFormFieldModule, MatSelectModule], templateUrl: './showcase-calendar-panels.component.html', styleUrls: ['./showcase-calendar-panels.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ShowcaseCalendarPanelsComponent implements OnInit { range: any; diff --git a/projects/lib-workspace/src/app/content/showcase-calendar-table/showcase-calendar-table.component.ts b/projects/lib-workspace/src/app/content/showcase-calendar-table/showcase-calendar-table.component.ts index 628ba17..76a4449 100644 --- a/projects/lib-workspace/src/app/content/showcase-calendar-table/showcase-calendar-table.component.ts +++ b/projects/lib-workspace/src/app/content/showcase-calendar-table/showcase-calendar-table.component.ts @@ -1,12 +1,13 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; -import { CalendarTableEntry, FsCalendarModule } from 'projects/ngx-mat-components/src/public-api'; +import { CalendarTableEntry, FsCalendarTableComponent, FsCalendarTableNameDirective } from 'projects/ngx-mat-components/src/public-api'; @Component({ selector: 'app-showcase-calendar-table', - imports: [CommonModule, MatCardModule, FsCalendarModule], + imports: [CommonModule, MatCardModule, FsCalendarTableComponent, FsCalendarTableNameDirective], templateUrl: './showcase-calendar-table.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ShowcaseCalendarTableComponent implements OnInit { today = new Date(); diff --git a/projects/lib-workspace/src/app/content/showcase-nav-frame/showcase-nav-frame.component.ts b/projects/lib-workspace/src/app/content/showcase-nav-frame/showcase-nav-frame.component.ts index fb86b73..0b1c723 100644 --- a/projects/lib-workspace/src/app/content/showcase-nav-frame/showcase-nav-frame.component.ts +++ b/projects/lib-workspace/src/app/content/showcase-nav-frame/showcase-nav-frame.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; @@ -7,6 +7,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog'; selector: 'app-showcase-nav-frame', imports: [CommonModule, MatButtonModule, MatDialogModule], templateUrl: './showcase-nav-frame.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ShowcaseNavFrameComponent implements OnInit { constructor(public dialog: MatDialog) {} @@ -28,5 +29,6 @@ export class ShowcaseNavFrameComponent implements OnInit { selector: 'dialog-content-example-dialog', templateUrl: 'dialog-content-example-dialog.html', imports: [MatDialogModule, MatButtonModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class DialogContentExampleDialog {} diff --git a/projects/lib-workspace/src/app/services/mockuser.service.ts b/projects/lib-workspace/src/app/services/mockuser.service.ts new file mode 100644 index 0000000..ace89fe --- /dev/null +++ b/projects/lib-workspace/src/app/services/mockuser.service.ts @@ -0,0 +1,25 @@ +import { Injectable, signal } from '@angular/core'; + +export interface UserProfile { + name: string; + subname: string; +} + +@Injectable({ providedIn: 'root' }) +export class MockUserService { + private readonly _user = signal(null); + + get user() { + return this._user.asReadonly(); + } + + loadUser() { + // Simulate async API call + setTimeout(() => { + this._user.set({ + name: 'Some User', + subname: 'Engineer', + }); + }, 5000); + } +} diff --git a/projects/ngx-mat-components/karma.conf.js b/projects/ngx-mat-components/karma.conf.js index 40e1057..18db8f8 100644 --- a/projects/ngx-mat-components/karma.conf.js +++ b/projects/ngx-mat-components/karma.conf.js @@ -5,13 +5,7 @@ module.exports = function (config) { config.set({ basePath: '', frameworks: ['jasmine', '@angular-devkit/build-angular'], - plugins: [ - require('karma-jasmine'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-coverage'), - require('@angular-devkit/build-angular/plugins/karma'), - ], + plugins: [require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage')], client: { jasmine: { // you can add configuration options for Jasmine here diff --git a/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.html b/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.html index d07d43a..cc97719 100644 --- a/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.html +++ b/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.html @@ -1,155 +1,138 @@ -
- -
-
- - -
-
-
- {{ month.monthName }} - {{ dataSource.config.displayYear ? month.year : '' }} +@if (calendar() !== undefined) { +
+ @for (month of calendar()?.calendarPanels; track month.monthName + '-' + month.year; let iMonth = $index) { +
+
+ @if (dataSource().config.switches && dataSource().config.renderMode != 'annual' && iMonth == 0) { + + } @else { +
+ } +
+ {{ month.monthName }} + {{ dataSource().config.displayYear ? month.year : '' }} +
+ @if (dataSource().config.switches && dataSource().config.renderMode != 'annual' && (calendar()?.calendarPanels?.length ?? 0) == iMonth + 1) { + + } @else { +
+ }
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - +
- {{ dayname }} -
-
    - -
-
- {{ day._meta?.dayNumber }} -
-
- -
-
-
- {{ placeholderDay ? day._meta?.kw : '' }} -
- -
{{ day._meta?.kw }}
-
-
-
- {{ day._meta?.dayNumber }} -
-
-
-
+ + + @if (dataSource().config.calendarWeek) { + + } + @for (dayname of calendar()?.dayNames; track dayname) { + + } - - + @if (dataSource().config.calendarWeek) { + + } + - - -
{{ dayname }}
-
- -
+ + + @for (row of month.render; track $index; let iRender = $index) { + + @for (day of row; track day.date.getTime() + '-' + $index; let iDay = $index) { + @if (day._meta?.type == 'day') { + + @if (day.toolTip) { +
    + } +
    + {{ day._meta?.dayNumber }} +
    +
    + + } + @if (day._meta?.type == 'cw') { + + @if (row[iDay + 1]._meta?.type == 'plHolder' && iRender != 0) { +
    {{ placeholderDay() ? day._meta?.kw : '' }}
    + } @else { +
    {{ day._meta?.kw }}
    + } + + } + @if (day._meta?.type == 'plHolder' && placeholderDay()) { + +
    + {{ day._meta?.dayNumber }} +
    + + } + @if (day._meta?.type == 'plHolder' && !placeholderDay()) { + +
    + + } + } + + + @if (dataSource().config.calendarWeek) { + + } + + + } + + +
    + } +
    +} diff --git a/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts b/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts index 1fa7592..710f4b7 100644 --- a/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts +++ b/projects/ngx-mat-components/src/fs-calendar/calendar-panels/calendar-panels.component.ts @@ -1,4 +1,6 @@ -import { Component, EventEmitter, HostListener, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, HostListener, inject, input, OnInit, output, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; import * as dateFns from 'date-fns'; import { CalendarEvent, CalendarExtendedDay, CalendarPanels, CalendarPanelSum } from '../calendar.models'; import { FsCalendarService } from '../services/fs-calendar.service'; @@ -7,14 +9,17 @@ import { FsCalendarService } from '../services/fs-calendar.service'; selector: 'fs-calendar-panels', templateUrl: './calendar-panels.component.html', styleUrls: ['./calendar-panels.component.scss'], + imports: [CommonModule, MatButtonModule], + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'fs-calendar-panels', 'data-component-id': 'fs-calendar-panels-unique', }, - standalone: false, }) export class FsCalendarPanelsComponent implements OnInit { - private _dataSource: CalendarPanels = { + private readonly calendarService = inject(FsCalendarService); + + dataSource = input>({ config: { renderMode: 'monthly', selectMode: 'click', @@ -27,108 +32,95 @@ export class FsCalendarPanelsComponent implements OnInit { panelWidth: '350px', }, data: [], - }; - - private _month = new Date().getUTCMonth(); - private _year: number = new Date().getFullYear(); - private _monthsBefore: number = 0; - private _monthsAfter: number = 0; - - calendar: CalendarPanelSum | undefined; - today = new Date(); - selectedDayStart: CalendarExtendedDay | undefined; - selectedDayBetween: CalendarExtendedDay[] = []; - selectedDayEnd: CalendarExtendedDay | undefined; - markWeekend = this._dataSource.config.markWeekend; - bluredDays = this._dataSource.config.bluredDays; - isLoading = true; - monthOverrride = false; - - weekendColor = 'rgba(0, 0, 0, .25)'; - - get dataSource(): CalendarPanels { - return this._dataSource; - } - get month(): number { - return this._month; - } - get year(): number { - return this._year; - } - get monthsBefore(): number { - return this._monthsBefore; - } - get monthsAfter(): number { - return this._monthsAfter; - } + }); - @Input() - set dataSource(data: CalendarPanels) { - this._dataSource = data; - this.generateCal(); - } - @Input() - set month(data: number) { - this._month = data; - this.monthOverrride = false; - this.generateCal(); - } - @Input() - set year(data: number) { - this._year = data; - this.generateCal(); - } - @Input() - set monthsBefore(data: number) { - this._monthsBefore = data; - this.generateCal(); - } - @Input() - set monthsAfter(data: number) { - this._monthsAfter = data; - this.generateCal(); - } - @Input() placeholderDay: boolean = false; + month = input(new Date().getUTCMonth()); + year = input(new Date().getFullYear()); + monthsBefore = input(0); + monthsAfter = input(0); + placeholderDay = input(false); - @Output() readonly selection = new EventEmitter>(); + selection = output>(); + + private readonly internalMonth = signal(new Date().getUTCMonth()); + private readonly internalYear = signal(new Date().getFullYear()); + + protected readonly calendar = computed(() => { + const ds = this.dataSource(); + const usedYear = this.monthOverrride() ? this.internalYear() : this.year(); + const usedMonth = this.monthOverrride() ? this.internalMonth() : this.month(); + + return this.calendarService.generateMatrix( + ds.config.renderMode, + ds.config.calendarWeek, + ds.data, + usedYear, + usedMonth, + this.monthsBefore(), + this.monthsAfter() + ); + }); + + protected readonly today = new Date(); + protected readonly selectedDayStart = signal | undefined>(undefined); + protected readonly selectedDayBetween = signal[]>([]); + protected readonly selectedDayEnd = signal | undefined>(undefined); + protected readonly monthOverrride = signal(false); + protected readonly isLoading = signal(true); + + protected readonly markWeekend = computed(() => this.dataSource().config.markWeekend); + protected readonly bluredDays = computed(() => this.dataSource().config.bluredDays); + protected readonly weekendColor = 'rgba(0, 0, 0, .25)'; + + constructor() { + // Sync input signals to internal signals on changes + effect(() => { + this.internalMonth.set(this.month()); + this.internalYear.set(this.year()); + }); + } @HostListener('window:keyup', ['$event']) keyEvent(event: KeyboardEvent) { if (event.key === 'Escape') { - this.selectedDayBetween = []; - this.selectedDayStart = undefined; - this.selectedDayEnd = undefined; + this.selectedDayBetween.set([]); + this.selectedDayStart.set(undefined); + this.selectedDayEnd.set(undefined); } } - constructor(private calendarService: FsCalendarService) {} - ngOnInit() { - this.isLoading = false; + this.isLoading.set(false); } onClick(day: CalendarExtendedDay, type: string) { - if (type === 'date' && this._dataSource.config.selectMode === 'range') { - if (this.selectedDayStart != undefined && this.selectedDayEnd != undefined) { - this.selectedDayBetween = []; - this.selectedDayStart = undefined; - this.selectedDayEnd = undefined; + const config = this.dataSource().config; + + if (type === 'date' && config.selectMode === 'range') { + const start = this.selectedDayStart(); + const end = this.selectedDayEnd(); + + if (start != undefined && end != undefined) { + this.selectedDayBetween.set([]); + this.selectedDayStart.set(undefined); + this.selectedDayEnd.set(undefined); } - if (dateFns.isBefore(day.date, this.selectedDayStart?.date as Date) || this.selectedDayStart === undefined) { - this.selectedDayStart = day; + + if (dateFns.isBefore(day.date, start?.date as Date) || start === undefined) { + this.selectedDayStart.set(day); } else { - this.selectedDayEnd = day; + this.selectedDayEnd.set(day); - let daysBetween: number = dateFns.differenceInDays(this.selectedDayStart.date, this.selectedDayEnd.date); + let daysBetween: number = dateFns.differenceInDays(start.date, day.date); let daysAffected: CalendarExtendedDay[] = []; - daysAffected.push(this.selectedDayStart); + daysAffected.push(start); if (daysBetween < 0) { for (let index = 1; index < daysBetween * -1 + 1; index++) { - let newGeneratedDay = this.calendarService.generateDay(dateFns.addDays(this.selectedDayStart.date, index), []); - let i = this.dataSource.data.findIndex(sd => dateFns.isSameDay(sd.date, newGeneratedDay.date)); + let newGeneratedDay = this.calendarService.generateDay(dateFns.addDays(start.date, index), []); + let i = this.dataSource().data.findIndex(sd => dateFns.isSameDay(sd.date, newGeneratedDay.date)); if (i != -1) { - daysAffected.push(this.dataSource.data[i]); + daysAffected.push(this.dataSource().data[i]); } else { daysAffected.push(newGeneratedDay); } @@ -137,8 +129,8 @@ export class FsCalendarPanelsComponent implements OnInit { this.selection.emit({ type: 'range', - start: this.selectedDayStart, - end: this.selectedDayEnd, + start: start, + end: day, affectedDays: daysAffected, }); } @@ -151,39 +143,46 @@ export class FsCalendarPanelsComponent implements OnInit { } onMouseOver(dateComp: Date) { - if (this.calendar != undefined) { - if (this.selectedDayStart != undefined && this.selectedDayEnd == undefined) { - this.selectedDayBetween = this.calendar.daysAbsolute.filter(date => { - return dateFns.isAfter(date.date, this.selectedDayStart?.date as Date) && dateFns.isBefore(date.date, dateComp); - }); + const cal = this.calendar(); + const start = this.selectedDayStart(); + const end = this.selectedDayEnd(); + + if (cal != undefined) { + if (start != undefined && end == undefined) { + this.selectedDayBetween.set( + cal.daysAbsolute.filter(date => { + return dateFns.isAfter(date.date, start.date) && dateFns.isBefore(date.date, dateComp); + }) + ); } } } getAmIBetween(date: Date): boolean { - const fIndex = this.selectedDayBetween.findIndex(selDate => { + const between = this.selectedDayBetween(); + const fIndex = between.findIndex(selDate => { return dateFns.isSameDay(selDate.date, date); }); - if (fIndex != -1) { - return true; - } else { - return false; - } + return fIndex != -1; } isSelectedDayStart(date: Date): boolean { - if (this.selectedDayStart) { - return dateFns.isSameDay(this.selectedDayStart.date, date); - } else { - return false; + const start = this.selectedDayStart(); + if (start) { + return dateFns.isSameDay(start.date, date); } + return false; } + isSelectedDayEnd(date: Date): boolean { - if (this.selectedDayEnd) { - return dateFns.isSameDay(this.selectedDayEnd.date, date); + const end = this.selectedDayEnd(); + const between = this.selectedDayBetween(); + + if (end) { + return dateFns.isSameDay(end.date, date); } else { - if (this.selectedDayBetween.length > 0) { - if (dateFns.isSameDay(this.calendarService.addDays(this.selectedDayBetween[this.selectedDayBetween.length - 1], 1).date, date)) { + if (between.length > 0) { + if (dateFns.isSameDay(this.calendarService.addDays(between[between.length - 1], 1).date, date)) { return true; } } @@ -196,54 +195,45 @@ export class FsCalendarPanelsComponent implements OnInit { } getCanIBeHighlighted(date: Date) { - if (this.selectedDayEnd) { + const start = this.selectedDayStart(); + const end = this.selectedDayEnd(); + + if (end) { if ( - (!dateFns.isSameDay(this.selectedDayStart?.date as Date, date) && !dateFns.isSameDay(this.selectedDayEnd?.date, date) && this.getAmIBetween(date)) || - (dateFns.isSameDay(this.selectedDayEnd?.date, date) && this.selectedDayEnd != undefined) || - (dateFns.isSameDay(this.selectedDayStart?.date as Date, date) && this.selectedDayStart != undefined) + (!dateFns.isSameDay(start?.date as Date, date) && !dateFns.isSameDay(end.date, date) && this.getAmIBetween(date)) || + (dateFns.isSameDay(end.date, date) && end != undefined) || + (dateFns.isSameDay(start?.date as Date, date) && start != undefined) ) { return true; - } else { - return false; } - } else { return false; } + return false; } onMonthForward() { - this.monthOverrride = true; - if (this.month >= 11 || this._month >= 11) { - this._year = parseInt(this.year.toString(), 10) + 1; - this._month = 0; + this.monthOverrride.set(true); + const currentMonth = this.internalMonth(); + const currentYear = this.internalYear(); + + if (currentMonth >= 11) { + this.internalYear.set(currentYear + 1); + this.internalMonth.set(0); } else { - this._month = parseInt(this._month.toString(), 10) + 1; + this.internalMonth.set(currentMonth + 1); } - this.generateCal(); } onMonthBackward() { - this.monthOverrride = true; - if (this.month <= 0 || this._month <= 0) { - this._year = parseInt(this.year.toString(), 10) - 1; - this._month = 11; + this.monthOverrride.set(true); + const currentMonth = this.internalMonth(); + const currentYear = this.internalYear(); + + if (currentMonth <= 0) { + this.internalYear.set(currentYear - 1); + this.internalMonth.set(11); } else { - this._month = parseInt(this._month.toString(), 10) - 1; + this.internalMonth.set(currentMonth - 1); } - this.generateCal(); - } - - private generateCal() { - const usedYear = this.monthOverrride ? this._year : this.year; - const usedMonth = this.monthOverrride ? this._month : this.month; - this.calendar = this.calendarService.generateMatrix( - this._dataSource.config.renderMode, - this._dataSource.config.calendarWeek, - this.dataSource.data, - usedYear, - usedMonth, - this.monthsBefore, - this.monthsAfter - ); } } diff --git a/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.html b/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.html index 70703e2..6175492 100644 --- a/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.html +++ b/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.html @@ -1,66 +1,73 @@
    - - - - - - - - - + @if (!isLoading()) { +
    - - - - - {{ day._meta?.dayNumber }} -
    - {{ currentMonth.dayNames[day._meta!.dayOfWeek - 1] }} -
    - -
    + + + + + @for (day of currentMonth().days; track day.date) { + + } + + + - - - - - - - -
    + + + + + {{ day._meta?.dayNumber }} +
    + {{ currentMonth().dayNames[day._meta!.dayOfWeek - 1] }} +
    + +
    {{ entry.name }} - {{ day.toolTip }} -
    - {{ day.char }} -
    -
    + + @for (entry of tableData(); track entry.name) { + + {{ entry.name }} + @for (day of entry.data; track day.date) { + + @if (day.toolTip) { + {{ day.toolTip }} + } +
    + {{ day.char }} +
    + + } + + + } + + + }
    diff --git a/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts b/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts index 3fcc4bf..e3ef09b 100644 --- a/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts +++ b/projects/ngx-mat-components/src/fs-calendar/calendar-table/fs-calendar-table.component.ts @@ -1,97 +1,93 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, OnInit, output, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import * as dateFns from 'date-fns'; import { CalendarMonth, CalendarTableEntry } from '../calendar.models'; import { FsCalendarService } from '../services/fs-calendar.service'; @Component({ selector: 'fs-calendar-table', + imports: [MatButtonModule], templateUrl: './fs-calendar-table.component.html', styleUrls: ['./fs-calendar-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'fs-calendar-table mat-mdc-card mdc-card mat-mdc-card-outlined mdc-card--outlined', 'data-component-id': 'fs-calendar-table-unique', }, - standalone: false, }) export class FsCalendarTableComponent implements OnInit { - isLoading: boolean = true; + private readonly calendarService = inject(FsCalendarService); - private _monthNumber: number = dateFns.getMonth(new Date()); - private _yearNumber: number = dateFns.getYear(new Date()); - private _dataSource: CalendarTableEntry[] = []; + dataSource = input([]); + month = input(dateFns.getMonth(new Date())); + year = input(dateFns.getYear(new Date())); - currentMonth: CalendarMonth = this.calendarService.generateMonth(this._yearNumber, this._monthNumber, []); - tableData: CalendarTableEntry[] = []; + monthChange = output(); + yearChange = output(); + private readonly internalMonth = signal(dateFns.getMonth(new Date())); + private readonly internalYear = signal(dateFns.getYear(new Date())); + + isLoading = signal(true); markWeekend = true; - get dataSource(): CalendarTableEntry[] { - return this._dataSource; - } - get month(): number { - this.monthChange.emit(this._monthNumber); - return this._monthNumber; - } - get year(): number { - this.yearChange.emit(this._yearNumber); - return this._yearNumber; - } + protected readonly currentMonth = computed(() => { + return this.calendarService.generateMonth(this.internalYear(), this.internalMonth(), []); + }); - @Input() - set dataSource(data: CalendarTableEntry[]) { - this._dataSource = data; - this.genMonthData(); - } + protected readonly tableData = computed(() => { + const data = this.dataSource(); + return data.map((item: CalendarTableEntry) => ({ + name: item.name, + data: this.calendarService.generateMonth(this.internalYear(), this.internalMonth(), item.data).days, + })); + }); - @Input() - set month(data: number) { - this._monthNumber = data; - this.genMonthData(); - } - @Output() monthChange = new EventEmitter(); + constructor() { + // Sync input signals to internal signals + effect(() => { + this.internalMonth.set(this.month()); + this.monthChange.emit(this.month()); + }); - @Input() - set year(data: number) { - this._yearNumber = data; - this.genMonthData(); + effect(() => { + this.internalYear.set(this.year()); + this.yearChange.emit(this.year()); + }); } - @Output() yearChange = new EventEmitter(); - - constructor(private calendarService: FsCalendarService) {} ngOnInit() { - this.genMonthData(); - this.isLoading = false; - } - - genMonthData() { - this.currentMonth = this.calendarService.generateMonth(this._yearNumber, this._monthNumber, []); - this._dataSource.forEach((item: CalendarTableEntry, index: number) => { - this.tableData.splice(index, 1, { - name: item.name, - data: this.calendarService.generateMonth(this.year, this.month, item.data).days, - }); - }); + this.isLoading.set(false); } onMonthForward() { - if (this._monthNumber >= 11) { - this._yearNumber++; - this._monthNumber = 0; + const currentMonth = this.internalMonth(); + const currentYear = this.internalYear(); + + if (currentMonth >= 11) { + this.internalYear.set(currentYear + 1); + this.internalMonth.set(0); } else { - this._monthNumber++; + this.internalMonth.set(currentMonth + 1); } - this.genMonthData(); + + this.monthChange.emit(this.internalMonth()); + this.yearChange.emit(this.internalYear()); } onMonthBackward() { - if (this._monthNumber <= 0) { - this._yearNumber--; - this._monthNumber = 11; + const currentMonth = this.internalMonth(); + const currentYear = this.internalYear(); + + if (currentMonth <= 0) { + this.internalYear.set(currentYear - 1); + this.internalMonth.set(11); } else { - this._monthNumber--; + this.internalMonth.set(currentMonth - 1); } - this.genMonthData(); + + this.monthChange.emit(this.internalMonth()); + this.yearChange.emit(this.internalYear()); } isToday(date: Date): boolean { diff --git a/projects/ngx-mat-components/src/fs-calendar/directives/fs-calendar-table-name.directive.ts b/projects/ngx-mat-components/src/fs-calendar/directives/fs-calendar-table-name.directive.ts index 52c5d46..bd17488 100644 --- a/projects/ngx-mat-components/src/fs-calendar/directives/fs-calendar-table-name.directive.ts +++ b/projects/ngx-mat-components/src/fs-calendar/directives/fs-calendar-table-name.directive.ts @@ -3,7 +3,6 @@ import { Directive } from '@angular/core'; @Directive({ selector: 'fs-calendar-table-name", [fsCalendarTableName"]', host: { class: 'fs-calendar-table-name"' }, - standalone: false, }) export class FsCalendarTableNameDirective { constructor() {} diff --git a/projects/ngx-mat-components/src/fs-calendar/fs-calendar.module.ts b/projects/ngx-mat-components/src/fs-calendar/fs-calendar.module.ts deleted file mode 100644 index 2e36837..0000000 --- a/projects/ngx-mat-components/src/fs-calendar/fs-calendar.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { FsCalendarPanelsComponent } from './calendar-panels/calendar-panels.component'; -import { FsCalendarTableComponent } from './calendar-table/fs-calendar-table.component'; -import { FsCalendarTableNameDirective } from './directives/fs-calendar-table-name.directive'; - -@NgModule({ - declarations: [FsCalendarPanelsComponent, FsCalendarTableComponent, FsCalendarTableNameDirective], - imports: [CommonModule, MatButtonModule], - exports: [FsCalendarPanelsComponent, FsCalendarTableComponent, FsCalendarTableNameDirective], -}) -export class FsCalendarModule {} diff --git a/projects/ngx-mat-components/src/fs-calendar/public-api.ts b/projects/ngx-mat-components/src/fs-calendar/public-api.ts index 6f5a962..5ce44da 100644 --- a/projects/ngx-mat-components/src/fs-calendar/public-api.ts +++ b/projects/ngx-mat-components/src/fs-calendar/public-api.ts @@ -6,5 +6,5 @@ export { FsCalendarPanelsComponent } from './calendar-panels/calendar-panels.com export { FsCalendarTableComponent } from './calendar-table/fs-calendar-table.component'; export * from './calendar.models'; export { FsCalendarTableNameDirective } from './directives/fs-calendar-table-name.directive'; -export { FsCalendarModule } from './fs-calendar.module'; +// Removed: export { FsCalendarModule } - Using standalone components now export { FsCalendarService } from './services/fs-calendar.service'; diff --git a/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.html b/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.html index 23dd768..0ef2894 100644 --- a/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.html +++ b/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.html @@ -1,4 +1,4 @@ - diff --git a/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts b/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts index 264657b..35ddc0d 100644 --- a/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts +++ b/projects/ngx-mat-components/src/fs-nav-frame/components/fs-nav-frame-sidebar-item/fs-nav-frame-sidebar-item.component.ts @@ -1,46 +1,34 @@ -import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, computed, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; +import { RouterLink, RouterLinkActive } from '@angular/router'; import { FsNavFrameService, MenuState } from '../../services/fs-nav-frame.service'; -import { Subject, takeUntil } from 'rxjs'; @Component({ selector: 'fs-nav-frame-sidebar-item', templateUrl: './fs-nav-frame-sidebar-item.component.html', styleUrls: ['./fs-nav-frame-sidebar-item.component.scss'], + imports: [RouterLink, RouterLinkActive], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'fs-nav-frame-sidebar-item', 'data-component-id': 'fs-nav-frame-sidebar-item-unique', }, - standalone: false, }) -export class FsNavFrameSidebarItemComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - @Input() routerLink: string | undefined; - @ViewChild(TemplateRef) template: TemplateRef | undefined; +export class FsNavFrameSidebarItemComponent { + // Input signals + readonly routerLink = input(); + readonly label = input(''); - closed: boolean = this.frameService.menuState == MenuState.CLOSED; + @ViewChild(TemplateRef) template: TemplateRef | undefined; - constructor(public frameService: FsNavFrameService) {} + // Computed signal from service + protected readonly closed = computed(() => this.frameService.isMenuClosed()); - ngOnInit() { - this.frameService.menuStateEvent.pipe(takeUntil(this.destroy$)).subscribe(state => { - if (state == MenuState.OPENED) { - this.closed = false; - } else { - this.closed = true; - } - }); - } + constructor(protected frameService: FsNavFrameService) {} - closeSidemenu() { - if (this.frameService.menuState == MenuState.OPENED) { + closeSidemenu(): void { + if (this.frameService.menuState() === MenuState.OPENED) { this.frameService.switchMenuState(); } } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/projects/ngx-mat-components/src/fs-nav-frame/directives/fs-nav-frame-content.directive.ts b/projects/ngx-mat-components/src/fs-nav-frame/directives/fs-nav-frame-content.directive.ts index 04628d3..5538c48 100644 --- a/projects/ngx-mat-components/src/fs-nav-frame/directives/fs-nav-frame-content.directive.ts +++ b/projects/ngx-mat-components/src/fs-nav-frame/directives/fs-nav-frame-content.directive.ts @@ -3,7 +3,6 @@ import { Directive } from '@angular/core'; @Directive({ selector: 'fs-nav-frame-content, [fsNavFrameContent]', host: { class: 'fs-nav-frame-content' }, - standalone: false, }) export class FsNavFrameContentDirective { constructor() {} diff --git a/projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.html b/projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.html index 47d9e6b..a2b8a5e 100644 --- a/projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.html +++ b/projects/ngx-mat-components/src/fs-nav-frame/fs-nav-frame.component.html @@ -1,36 +1,32 @@ -