styling
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
<main>
|
<router-outlet></router-outlet>
|
||||||
<router-outlet></router-outlet>
|
|
||||||
</main>
|
|
||||||
<app-snackbar-container></app-snackbar-container>
|
<app-snackbar-container></app-snackbar-container>
|
||||||
<app-cookie-consent></app-cookie-consent>
|
<app-cookie-consent></app-cookie-consent>
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
<div class="dashboard-grid">
|
<main>
|
||||||
|
<app-slide-toggle
|
||||||
|
label="Dark Mode"
|
||||||
|
[ngModel]="darkModeAktiv"
|
||||||
|
(ngModelChange)="onDarkModeChange($event)"
|
||||||
|
name="mainDarkModeToggle"
|
||||||
|
></app-slide-toggle>
|
||||||
|
|
||||||
|
<div class="dashboard-grid grid-col-span-4">
|
||||||
<app-kpi-card
|
<app-kpi-card
|
||||||
*ngFor="let kpi of kpiData"
|
*ngFor="let kpi of kpiData"
|
||||||
[value]="kpi.value"
|
[value]="kpi.value"
|
||||||
@@ -7,13 +15,69 @@
|
|||||||
[iconName]="kpi.iconName"
|
[iconName]="kpi.iconName"
|
||||||
>
|
>
|
||||||
</app-kpi-card>
|
</app-kpi-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-orders-table
|
<div class="grid-col-span-4">
|
||||||
|
<app-form-group title="Table">
|
||||||
|
<app-orders-table
|
||||||
[data]="ordersData"
|
[data]="ordersData"
|
||||||
[itemsPerPage]="5"
|
[itemsPerPage]="5"
|
||||||
(view)="handleViewDetails($event)"
|
(view)="handleViewDetails($event)"
|
||||||
(edit)="handleEditOrder($event)"
|
(edit)="handleEditOrder($event)"
|
||||||
(delete)="handleDeleteOrder($event)"
|
(delete)="handleDeleteOrder($event)"
|
||||||
>
|
>
|
||||||
</app-orders-table>
|
</app-orders-table>
|
||||||
|
</app-form-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-col-span-2">
|
||||||
|
<app-form-group
|
||||||
|
title="Persönliche Daten"
|
||||||
|
description="Diese Informationen sind öffentlich sichtbar."
|
||||||
|
>
|
||||||
|
<app-form-select
|
||||||
|
label="Land"
|
||||||
|
[options]="countryOptions"
|
||||||
|
[(ngModel)]="selectedCountry"
|
||||||
|
>
|
||||||
|
</app-form-select>
|
||||||
|
|
||||||
|
<app-form-field label="Benutzername" type="text" [(value)]="benutzername">
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<app-form-field label="E-Mail-Adresse" type="email" [(value)]="email">
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<app-form-textarea label="Biografie" [rows]="5" [(ngModel)]="biografie">
|
||||||
|
</app-form-textarea>
|
||||||
|
</app-form-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-col-span-2">
|
||||||
|
<app-form-group
|
||||||
|
title="Anwendungs-Einstellungen"
|
||||||
|
description="Diese Informationen sind öffentlich sichtbar."
|
||||||
|
>
|
||||||
|
<app-slide-toggle
|
||||||
|
labelPosition="right"
|
||||||
|
label="Push-Benachrichtigungen"
|
||||||
|
[(ngModel)]="benachrichtigungenAktiv"
|
||||||
|
name="notificationsToggle"
|
||||||
|
></app-slide-toggle>
|
||||||
|
|
||||||
|
<app-slide-toggle
|
||||||
|
label="Dark Mode"
|
||||||
|
[ngModel]="darkModeAktiv"
|
||||||
|
(ngModelChange)="onDarkModeChange($event)"
|
||||||
|
name="mainDarkModeToggle"
|
||||||
|
></app-slide-toggle>
|
||||||
|
|
||||||
|
<app-slide-toggle
|
||||||
|
label="Privates Profil (Feature gesperrt)"
|
||||||
|
[(ngModel)]="profilIstPrivat"
|
||||||
|
name="privacyToggle"
|
||||||
|
[disabled]="true"
|
||||||
|
></app-slide-toggle>
|
||||||
|
</app-form-group>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Component } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnInit,
|
||||||
|
PLATFORM_ID,
|
||||||
|
Renderer2,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CardComponent } from '../../../../shared/components/ui/card/card.component';
|
import { CardComponent } from '../../../../shared/components/ui/card/card.component';
|
||||||
// Wir müssen KpiColor hier importieren, um es als Typ verwenden zu können
|
// Wir müssen KpiColor hier importieren, um es als Typ verwenden zu können
|
||||||
@@ -10,7 +17,20 @@ import {
|
|||||||
OrdersTableComponent,
|
OrdersTableComponent,
|
||||||
Order,
|
Order,
|
||||||
} from '../../../../shared/components/data-display/orders-table/orders-table.component';
|
} from '../../../../shared/components/data-display/orders-table/orders-table.component';
|
||||||
import { PaginatorComponent } from '../../../../shared/components/data-display/paginator/paginator.component';
|
import { FormFieldComponent } from '../../../../shared/components/form/form-field/form-field.component';
|
||||||
|
import { FormGroupComponent } from '../../../../shared/components/form/form-group/form-group.component';
|
||||||
|
import {
|
||||||
|
FormSelectComponent,
|
||||||
|
SelectOption,
|
||||||
|
} from '../../../../shared/components/form/form-select/form-select.component';
|
||||||
|
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import { FormTextareaComponent } from '../../../../shared/components/form/form-textarea/form-textarea.component';
|
||||||
|
|
||||||
|
import { SlideToggleComponent } from '../../../../shared/components/form/slide-toggle/slide-toggle.component';
|
||||||
|
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
// Wir definieren ein Interface für unsere KPI-Daten für Typsicherheit
|
// Wir definieren ein Interface für unsere KPI-Daten für Typsicherheit
|
||||||
interface Kpi {
|
interface Kpi {
|
||||||
@@ -25,10 +45,15 @@ interface Kpi {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
CardComponent,
|
|
||||||
KpiCardComponent,
|
KpiCardComponent,
|
||||||
OrdersTableComponent,
|
OrdersTableComponent,
|
||||||
PaginatorComponent,
|
FormGroupComponent,
|
||||||
|
FormFieldComponent,
|
||||||
|
FormSelectComponent,
|
||||||
|
FormsModule,
|
||||||
|
FormTextareaComponent,
|
||||||
|
SlideToggleComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './demo2.component.html',
|
templateUrl: './demo2.component.html',
|
||||||
})
|
})
|
||||||
@@ -127,21 +152,111 @@ export class Demo2Component {
|
|||||||
status: 'danger',
|
status: 'danger',
|
||||||
statusText: 'Storniert',
|
statusText: 'Storniert',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: '10543',
|
||||||
|
user: {
|
||||||
|
name: 'Max Mustermann',
|
||||||
|
email: 'max.m@example.com',
|
||||||
|
avatarUrl: 'https://i.pravatar.cc/40?u=max',
|
||||||
|
},
|
||||||
|
amount: '€ 129,99',
|
||||||
|
status: 'success',
|
||||||
|
statusText: 'Abgeschlossen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10542',
|
||||||
|
user: {
|
||||||
|
name: 'Erika Mustermann',
|
||||||
|
email: 'erika.m@example.com',
|
||||||
|
avatarUrl: 'https://i.pravatar.cc/40?u=erika',
|
||||||
|
},
|
||||||
|
amount: '€ 49,50',
|
||||||
|
status: 'warning',
|
||||||
|
statusText: 'In Bearbeitung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10541',
|
||||||
|
user: {
|
||||||
|
name: 'Peter Pan',
|
||||||
|
email: 'peter.p@example.com',
|
||||||
|
avatarUrl: 'https://i.pravatar.cc/40?u=peter',
|
||||||
|
},
|
||||||
|
amount: '€ 87,00',
|
||||||
|
status: 'danger',
|
||||||
|
statusText: 'Storniert',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Eigenschaften für den Paginator
|
benutzername: string = '';
|
||||||
currentPage = 1;
|
email: string = '';
|
||||||
totalItems = this.ordersData.length;
|
aktuellesPasswort: any = '';
|
||||||
itemsPerPage = 10;
|
neuesPasswort: any = '';
|
||||||
|
|
||||||
// --- EVENT-HANDLER FÜR DIE KIND-KOMPONENTEN ---
|
selectedCountry: string | null = null;
|
||||||
|
|
||||||
onPageChange(newPage: number): void {
|
countryOptions: SelectOption[] = [
|
||||||
this.currentPage = newPage;
|
{ value: 'de', label: 'Deutschland' },
|
||||||
console.log('Seite gewechselt zu:', newPage);
|
{ value: 'at', label: 'Österreich' },
|
||||||
// In einer echten Anwendung würden Sie hier die Daten neu laden
|
{ value: 'ch', label: 'Schweiz' },
|
||||||
|
];
|
||||||
|
|
||||||
|
biografie: string =
|
||||||
|
'Dies ist eine Beispiel-Biografie, die über mehrere Zeilen gehen kann.';
|
||||||
|
|
||||||
|
benachrichtigungenAktiv: boolean = true;
|
||||||
|
profilIstPrivat: boolean = false;
|
||||||
|
|
||||||
|
private readonly darkModeKey = 'app-dark-mode-setting';
|
||||||
|
darkModeAktiv: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private renderer: Renderer2,
|
||||||
|
@Inject(DOCUMENT) private document: Document,
|
||||||
|
@Inject(PLATFORM_ID) private platformId: Object
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadThemeSetting();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadThemeSetting(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
try {
|
||||||
|
const storedValue = localStorage.getItem(this.darkModeKey);
|
||||||
|
// Setze den Zustand der Komponente basierend auf dem gespeicherten Wert.
|
||||||
|
this.darkModeAktiv = storedValue === 'true';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Could not access localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wende das Theme an - entweder den Standardwert (false) oder den geladenen Wert.
|
||||||
|
this.updateTheme(this.darkModeAktiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDarkModeChange(isEnabled: boolean): void {
|
||||||
|
this.darkModeAktiv = isEnabled;
|
||||||
|
|
||||||
|
// --- KORREKTUR 2: Speichere die neue Einstellung ---
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.darkModeKey, String(isEnabled));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Could not write to localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wende die visuelle Änderung an.
|
||||||
|
this.updateTheme(isEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method is CORRECT. It just needs to be called.
|
||||||
|
private updateTheme(isEnabled: boolean): void {
|
||||||
|
if (isEnabled) {
|
||||||
|
this.renderer.addClass(this.document.body, 'dark-theme');
|
||||||
|
} else {
|
||||||
|
this.renderer.removeClass(this.document.body, 'dark-theme');
|
||||||
|
}
|
||||||
|
}
|
||||||
handleDeleteOrder(orderId: string): void {
|
handleDeleteOrder(orderId: string): void {
|
||||||
console.log('Lösche Bestellung mit ID:', orderId);
|
console.log('Lösche Bestellung mit ID:', orderId);
|
||||||
// Hier könnten Sie z.B. einen Bestätigungs-Dialog öffnen
|
// Hier könnten Sie z.B. einen Bestätigungs-Dialog öffnen
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/* =================================================================================
|
||||||
|
* STILE FÜR DIE FORM-FIELD KOMPONENTE (FLOATING LABEL)
|
||||||
|
* ================================================================================= */
|
||||||
|
|
||||||
|
/* Stellt sicher, dass die Komponente den Raum korrekt einnimmt. */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Hauptcontainer.
|
||||||
|
* `position: relative` ist ZWINGEND ERFORDERLICH, um den visuellen
|
||||||
|
* Bezugspunkt für das absolut positionierte Label zu schaffen.
|
||||||
|
*/
|
||||||
|
.form-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das eigentliche Eingabefeld */
|
||||||
|
.form-input {
|
||||||
|
/* Layout & Box-Modell */
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
|
||||||
|
/* Aussehen & Typografie */
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit; /* Erbt die Schriftart von der Seite */
|
||||||
|
|
||||||
|
/* Verhalten */
|
||||||
|
transition: border-color var(--transition-speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visuelles Feedback, wenn das Feld aktiv ist. */
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none; /* Entfernt den Standard-Browser-Rahmen */
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das Label, das über dem Input "schwebt" */
|
||||||
|
.form-label {
|
||||||
|
/* Positionierung */
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; /* Vertikal zentrieren (Schritt 1) */
|
||||||
|
left: 1rem; /* Linken Abstand wie beim Input-Padding halten */
|
||||||
|
transform: translateY(-50%); /* Vertikal zentrieren (Schritt 2 - Feinjustierung) */
|
||||||
|
|
||||||
|
border-radius: 4px;
|
||||||
|
/* Aussehen & Typografie */
|
||||||
|
color: var(--color-text-light);
|
||||||
|
background-color: var(--color-surface); /* WICHTIG: Überdeckt die Input-Linie beim Schweben */
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
|
||||||
|
/* Verhalten */
|
||||||
|
transition: all 0.2s ease-out; /* Animiert alle Änderungen (top, font-size, color) */
|
||||||
|
pointer-events: none; /* Erlaubt Klicks "durch" das Label auf das Input-Feld darunter */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================================
|
||||||
|
* "FLOATING"-LOGIK: Wann bewegt sich das Label nach oben?
|
||||||
|
* ================================================================================= */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der `~` (General Sibling Combinator) ist der Schlüssel. Er wählt das `.form-label` aus,
|
||||||
|
* das ein Geschwister von `.form-input` ist und nach ihm im HTML kommt.
|
||||||
|
*
|
||||||
|
* Das Label bewegt sich nach oben, WENN:
|
||||||
|
* 1. Der Input den Fokus hat ODER
|
||||||
|
* 2. Der Input NICHT seinen Platzhalter anzeigt (also Text enthält).
|
||||||
|
* (Funktioniert nur, weil im HTML `placeholder=" "` gesetzt ist)
|
||||||
|
*/
|
||||||
|
.form-input:focus ~ .form-label,
|
||||||
|
.form-input:not(:placeholder-shown) ~ .form-label {
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(-50%); /* Bleibt für die Zentrierung auf der Border-Linie wichtig */
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ZUSÄTZLICHE REGEL FÜR BROWSER-AUTOFILL
|
||||||
|
* Chrome & Co. färben den Hintergrund von automatisch ausgefüllten Feldern.
|
||||||
|
* Diese Regel sorgt dafür, dass unser Label auch in diesem Fall nach oben wandert
|
||||||
|
* und die richtige Farbe bekommt.
|
||||||
|
*/
|
||||||
|
.form-input:-webkit-autofill,
|
||||||
|
.form-input:-webkit-autofill:hover,
|
||||||
|
.form-input:-webkit-autofill:focus,
|
||||||
|
.form-input:-webkit-autofill:active {
|
||||||
|
/* OPTIONAL: Überschreibt den unschönen gelben/blauen Autofill-Hintergrund */
|
||||||
|
-webkit-box-shadow: 0 0 0 30px var(--color-surface) inset !important;
|
||||||
|
-webkit-text-fill-color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:-webkit-autofill ~ .form-label {
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
@@ -4,9 +4,8 @@
|
|||||||
class="form-input"
|
class="form-input"
|
||||||
[id]="label"
|
[id]="label"
|
||||||
placeholder=" "
|
placeholder=" "
|
||||||
[(ngModel)]="value"
|
[value]="value"
|
||||||
(ngModelChange)="onChange($event)"
|
(input)="onInput($event)"
|
||||||
(blur)="onTouched()"
|
|
||||||
[disabled]="disabled">
|
[disabled]="disabled">
|
||||||
<label [for]="label" class="form-label">{{ label }}</label>
|
<label [for]="label" class="form-label">{{ label }}</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,46 +1,32 @@
|
|||||||
import { Component, Input, forwardRef } from '@angular/core';
|
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||||
// ControlValueAccessor und NG_VALUE_ACCESSOR sind Typen, kein Modul
|
|
||||||
import {
|
|
||||||
ControlValueAccessor,
|
|
||||||
NG_VALUE_ACCESSOR,
|
|
||||||
FormsModule,
|
|
||||||
} from '@angular/forms'; // <-- FormsModule hier importieren
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-form-field',
|
selector: 'app-form-field',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule], // Kein FormsModule mehr nötig
|
||||||
templateUrl: './form-field.component.html',
|
templateUrl: './form-field.component.html',
|
||||||
styleUrl: '../form.css',
|
styleUrl: './form-field.component.css',
|
||||||
providers: [
|
// Kein 'providers'-Block für ControlValueAccessor mehr
|
||||||
{
|
|
||||||
provide: NG_VALUE_ACCESSOR,
|
|
||||||
useExisting: forwardRef(() => FormFieldComponent),
|
|
||||||
multi: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class FormFieldComponent implements ControlValueAccessor {
|
export class FormFieldComponent {
|
||||||
|
// EINGÄNGE: Werte, die von außen gesetzt werden
|
||||||
@Input() label: string = '';
|
@Input() label: string = '';
|
||||||
@Input() type: 'text' | 'email' | 'password' = 'text';
|
@Input() type: 'text' | 'email' | 'password' = 'text';
|
||||||
|
@Input() value: string = ''; // Der aktuelle Wert des Feldes
|
||||||
|
@Input() disabled: boolean = false;
|
||||||
|
|
||||||
// Interne Logik für ControlValueAccessor
|
// AUSGANG: Ein Event, das ausgelöst wird, wenn sich der Wert ändert
|
||||||
value: string = '';
|
// WICHTIG: Der Name muss `[InputName]Change` sein, also `valueChange`
|
||||||
onChange: (value: any) => void = () => {};
|
@Output() valueChange = new EventEmitter<string>();
|
||||||
onTouched: () => void = () => {};
|
|
||||||
disabled = false;
|
|
||||||
|
|
||||||
writeValue(value: any): void {
|
/**
|
||||||
this.value = value;
|
* Diese Methode wird bei jeder Tastatureingabe im Input-Feld aufgerufen.
|
||||||
}
|
*/
|
||||||
registerOnChange(fn: any): void {
|
onInput(event: Event): void {
|
||||||
this.onChange = fn;
|
// 1. Hole den neuen Wert aus dem HTML-Input-Element
|
||||||
}
|
const newValue = (event.target as HTMLInputElement).value;
|
||||||
registerOnTouched(fn: any): void {
|
// 2. Sende den neuen Wert über den EventEmitter nach außen
|
||||||
this.onTouched = fn;
|
this.valueChange.emit(newValue);
|
||||||
}
|
|
||||||
setDisabledState?(isDisabled: boolean): void {
|
|
||||||
this.disabled = isDisabled;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
/* Stil für den Kasten um die Gruppe */
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-title {
|
||||||
|
/* Stil für die Überschrift */
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0 0.5rem; /* Sorgt dafür, dass der Titel die Linie schön unterbricht */
|
||||||
|
margin-left: 0.5rem; /* Kleine Einrückung */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-description {
|
||||||
|
/* Stil für die optionale Beschreibung */
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin-top: -0.75rem; /* Rücken wir näher an den Titel */
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-content {
|
||||||
|
/* Sorgt für einen einheitlichen Abstand zwischen den Feldern in der Gruppe */
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<fieldset class="form-group">
|
||||||
|
<!-- Der Titel wird in einem <legend>-Tag angezeigt -->
|
||||||
|
<legend *ngIf="title" class="form-group-title">
|
||||||
|
{{ title }}
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<!-- Die optionale Beschreibung -->
|
||||||
|
<p *ngIf="description" class="form-group-description">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
HIER IST DIE MAGIE:
|
||||||
|
<ng-content> ist ein Platzhalter. Alles, was Sie in Demo2Component
|
||||||
|
zwischen <app-form-group> und </app-form-group> schreiben,
|
||||||
|
wird genau an dieser Stelle eingefügt.
|
||||||
|
-->
|
||||||
|
<div class="form-group-content">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-form-group',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './form-group.component.html',
|
||||||
|
styleUrl: './form-group.component.css',
|
||||||
|
})
|
||||||
|
export class FormGroupComponent {
|
||||||
|
// EINGANG: Der Titel, der über der Gruppe angezeigt wird.
|
||||||
|
@Input() title: string = '';
|
||||||
|
|
||||||
|
// EINGANG (Optional): Eine kleine Beschreibung unter dem Titel.
|
||||||
|
@Input() description: string = '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/* =================================================================================
|
||||||
|
* STILE FÜR DIE CUSTOM SELECT KOMPONENTE
|
||||||
|
* ================================================================================= */
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Haupt-Wrapper */
|
||||||
|
.custom-select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
/* Layout & Box-Modell */
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
|
||||||
|
/* Aussehen & Typografie */
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit; /* Erbt die Schriftart von der Seite */
|
||||||
|
|
||||||
|
/* Verhalten */
|
||||||
|
transition: border-color var(--transition-speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das "schwebende" Label (angepasste Logik) */
|
||||||
|
.form-label {
|
||||||
|
/* Positionierung */
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; /* Vertikal zentrieren (Schritt 1) */
|
||||||
|
left: 1rem; /* Linken Abstand wie beim Input-Padding halten */
|
||||||
|
transform: translateY(
|
||||||
|
-50%
|
||||||
|
); /* Vertikal zentrieren (Schritt 2 - Feinjustierung) */
|
||||||
|
border-radius: 4px;
|
||||||
|
/* Aussehen & Typografie */
|
||||||
|
color: var(--color-text-light);
|
||||||
|
background-color: var(
|
||||||
|
--color-surface
|
||||||
|
); /* WICHTIG: Überdeckt die Input-Linie beim Schweben */
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
|
||||||
|
/* Verhalten */
|
||||||
|
transition: all 0.2s ease-out; /* Animiert alle Änderungen (top, font-size, color) */
|
||||||
|
pointer-events: none; /* Erlaubt Klicks "durch" das Label auf das Input-Feld darunter */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Basis-Stile für den sichtbaren Teil (der Button) */
|
||||||
|
.form-input.select-display {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 2.5rem; /* Platz für den Pfeil */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button-Reset, damit er wie ein Input aussieht */
|
||||||
|
button.form-input {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================================
|
||||||
|
* KORRIGIERTER BLOCK FÜR DIE NACHRÜCK-ANIMATION
|
||||||
|
* ================================================================================= */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* KORREKTUR 1: Machen den Container zu einer Flexbox.
|
||||||
|
* Das ist der Schlüssel, damit der Browser die Position des zweiten Elements animiert,
|
||||||
|
* während das erste schrumpft.
|
||||||
|
*/
|
||||||
|
.placeholder-text {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* KORREKTUR 2: Wir stellen sicher, dass das erste span (das Label) auch ein Flex-Item ist,
|
||||||
|
* das schrumpfen kann. Der Rest der Animation bleibt gleich.
|
||||||
|
*/
|
||||||
|
.placeholder-text .label {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
color: transparent;
|
||||||
|
padding-right: 8px;
|
||||||
|
max-width: 100px;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Trigger bleibt gleich: Bei Fokus schrumpft das Label auf 0,
|
||||||
|
* und die Flexbox schiebt den "wählen..."-Text sanft an seine neue Position.
|
||||||
|
*/
|
||||||
|
.select-display:focus .placeholder-text .label {
|
||||||
|
max-width: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
/* ================================================================================= */
|
||||||
|
|
||||||
|
/* Ausgewählter Text */
|
||||||
|
.selected-text {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fokus-Zustand */
|
||||||
|
.select-display:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Floating"-Label-Logik */
|
||||||
|
.select-display:focus ~ .form-label,
|
||||||
|
.form-label.has-value {
|
||||||
|
top: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================================
|
||||||
|
* STILE FÜR DEN PFEIL UND DAS DROPDOWN-MENÜ
|
||||||
|
* ================================================================================= */
|
||||||
|
/* ... (der Rest der CSS-Datei bleibt unverändert) ... */
|
||||||
|
.select-display::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 1rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 0.8em;
|
||||||
|
height: 0.5em;
|
||||||
|
background-color: var(--color-text-light);
|
||||||
|
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
|
||||||
|
transition: transform 0.2s ease-out, background-color var(--transition-speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-display:focus::after {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-wrapper.is-open .select-display::after {
|
||||||
|
transform: translateY(-50%) rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-list {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
box-shadow: var(--box-shadow-md);
|
||||||
|
z-index: 1000;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: fadeInDown 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item:hover {
|
||||||
|
background-color: var(--color-body-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,42 @@
|
|||||||
<div class="form-field">
|
<div class="custom-select-wrapper" [class.is-open]="isOpen">
|
||||||
<select
|
<!--
|
||||||
class="form-input"
|
Das ist der sichtbare Teil, der wie ein Input aussieht.
|
||||||
[id]="controlId"
|
Er zeigt den ausgewählten Wert an und dient als Button zum Öffnen.
|
||||||
[(ngModel)]="value"
|
-->
|
||||||
(ngModelChange)="onChange($event)"
|
<button
|
||||||
|
type="button"
|
||||||
|
class="form-input select-display"
|
||||||
|
(click)="toggleDropdown($event)"
|
||||||
(blur)="onTouched()"
|
(blur)="onTouched()"
|
||||||
[disabled]="disabled">
|
[disabled]="disabled"
|
||||||
|
[id]="controlId"
|
||||||
|
>
|
||||||
|
<!-- Zeigt entweder das ausgewählte Label oder den Platzhalter-Text an -->
|
||||||
|
<span *ngIf="selectedLabel; else placeholder" class="selected-text">{{
|
||||||
|
selectedLabel
|
||||||
|
}}</span>
|
||||||
|
<ng-template #placeholder>
|
||||||
|
<span class="placeholder-text"
|
||||||
|
><span class="label">{{ label }}</span> <span> wählen...</span></span
|
||||||
|
>
|
||||||
|
</ng-template>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Platzhalter-Option, die angezeigt wird, wenn kein Wert ausgewählt ist -->
|
<label
|
||||||
<option [ngValue]="null" disabled>Bitte wählen...</option>
|
[for]="controlId"
|
||||||
|
class="form-label"
|
||||||
|
[class.has-value]="value !== null"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
<!-- Schleife, die die übergebenen Optionen rendert -->
|
<ul *ngIf="isOpen" [@dropdownAnimation] class="options-list">
|
||||||
<option *ngFor="let option of options" [ngValue]="option.value">
|
<li
|
||||||
|
*ngFor="let option of options"
|
||||||
|
class="option-item"
|
||||||
|
(click)="selectOption(option, $event)"
|
||||||
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</option>
|
</li>
|
||||||
|
</ul>
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Das Label ist immer vorhanden -->
|
|
||||||
<label [for]="controlId" class="form-label">{{ label }}</label>
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Component, Input, forwardRef } from '@angular/core';
|
import { Component, Input, forwardRef, HostListener, ElementRef } from '@angular/core';
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms'; // FormsModule importieren
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { animate, style, transition, trigger } from '@angular/animations';
|
||||||
|
|
||||||
// Interface für die Select-Optionen
|
// Interface bleibt unverändert
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: any;
|
value: any;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -11,34 +12,87 @@ export interface SelectOption {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-form-select',
|
selector: 'app-form-select',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [CommonModule, ReactiveFormsModule, FormsModule],
|
||||||
CommonModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
FormsModule // <-- HIER IST DIE KORREKTUR
|
|
||||||
],
|
|
||||||
templateUrl: './form-select.component.html',
|
templateUrl: './form-select.component.html',
|
||||||
styleUrl: '../form.css',
|
styleUrl: './form-select.component.css',
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
useExisting: forwardRef(() => FormSelectComponent),
|
useExisting: forwardRef(() => FormSelectComponent),
|
||||||
multi: true
|
multi: true,
|
||||||
}
|
},
|
||||||
|
],
|
||||||
|
animations: [
|
||||||
|
trigger('dropdownAnimation', [
|
||||||
|
// :enter ist ein Alias für den Übergang von "void => *" (wenn *ngIf true wird)
|
||||||
|
transition(':enter', [
|
||||||
|
style({ opacity: 0, transform: 'translateY(-10px)' }), // Start-Zustand (unsichtbar)
|
||||||
|
animate('0.2s ease-out', style({ opacity: 1, transform: 'translateY(0)' })) // End-Zustand (sichtbar)
|
||||||
|
]),
|
||||||
|
// :leave ist ein Alias für den Übergang von "* => void" (wenn *ngIf false wird)
|
||||||
|
transition(':leave', [
|
||||||
|
animate('0.2s ease-in', style({ opacity: 0, transform: 'translateY(-10px)' })) // Animiert zum unsichtbaren Zustand
|
||||||
|
])
|
||||||
|
])
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class FormSelectComponent implements ControlValueAccessor {
|
export class FormSelectComponent implements ControlValueAccessor {
|
||||||
@Input() label: string = '';
|
@Input() label: string = '';
|
||||||
@Input() options: SelectOption[] = [];
|
@Input() options: SelectOption[] = [];
|
||||||
|
|
||||||
// ... (der Rest der Klasse bleibt unverändert)
|
// NEU: Zustand für das Dropdown-Menü und das angezeigte Label
|
||||||
|
isOpen = false;
|
||||||
|
selectedLabel: string | null = null;
|
||||||
|
|
||||||
controlId = `form-select-${Math.random().toString(36).substring(2)}`;
|
controlId = `form-select-${Math.random().toString(36).substring(2)}`;
|
||||||
value: any = null;
|
value: any = null;
|
||||||
onChange: (value: any) => void = () => {};
|
onChange: (value: any) => void = () => {};
|
||||||
onTouched: () => void = () => {};
|
onTouched: () => void = () => {};
|
||||||
disabled = false;
|
disabled = false;
|
||||||
|
|
||||||
writeValue(value: any): void { this.value = value; }
|
constructor(private elementRef: ElementRef) {}
|
||||||
|
|
||||||
|
// NEU: Schließt das Dropdown, wenn außerhalb des Elements geklickt wird
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onDocumentClick(event: MouseEvent): void {
|
||||||
|
// Diese Logik ist jetzt sicher, weil Klicks innerhalb der Komponente sie nie erreichen
|
||||||
|
if (!this.elementRef.nativeElement.contains(event.target)) {
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ÜBERARBEITET: writeValue aktualisiert jetzt auch das sichtbare Label
|
||||||
|
writeValue(value: any): void {
|
||||||
|
this.value = value;
|
||||||
|
const selectedOption = this.options.find(opt => opt.value === value);
|
||||||
|
this.selectedLabel = selectedOption ? selectedOption.label : null;
|
||||||
|
}
|
||||||
|
|
||||||
registerOnChange(fn: any): void { this.onChange = fn; }
|
registerOnChange(fn: any): void { this.onChange = fn; }
|
||||||
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
||||||
setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; }
|
setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; }
|
||||||
|
|
||||||
|
// NEU: Methode zum Öffnen/Schließen des Menüs
|
||||||
|
// ÜBERARBEITET: Nimmt das Event entgegen und stoppt es
|
||||||
|
toggleDropdown(event: MouseEvent): void {
|
||||||
|
event.stopPropagation(); // <-- WICHTIGSTE KORREKTUR
|
||||||
|
if (!this.disabled) {
|
||||||
|
this.isOpen = !this.isOpen;
|
||||||
|
if (!this.isOpen) {
|
||||||
|
this.onTouched();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ÜBERARBEITET: Nimmt das Event entgegen und stoppt es
|
||||||
|
selectOption(option: SelectOption, event: MouseEvent): void {
|
||||||
|
event.stopPropagation(); // <-- WICHTIGE KORREKTUR
|
||||||
|
if (!this.disabled) {
|
||||||
|
this.value = option.value;
|
||||||
|
this.selectedLabel = option.label;
|
||||||
|
this.onChange(this.value);
|
||||||
|
this.onTouched();
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* =================================================================================
|
||||||
|
* STILE FÜR DIE FORM-TEXTAREA KOMPONENTE
|
||||||
|
* ================================================================================= */
|
||||||
|
|
||||||
|
/* Stellt sicher, dass die Host-Komponente als Block-Element gerendert wird. */
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Container, der das Textarea-Feld und das Label umschließt. */
|
||||||
|
.form-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gemeinsame Stile, die sowohl für <input> als auch für <textarea> gelten.
|
||||||
|
Die Klasse .form-input wird hier wiederverwendet. */
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color var(--transition-speed);
|
||||||
|
/* Stellt sicher, dass die Größe der Textarea nicht manuell verändert werden kann,
|
||||||
|
um das Layout nicht zu zerstören. Kann auf `vertical` geändert werden, falls gewünscht. */
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ändert die Rahmenfarbe, wenn der Benutzer in das Feld klickt. */
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================================
|
||||||
|
* "FLOATING LABEL" LOGIK - ANGEPASST FÜR TEXTAREA
|
||||||
|
* ================================================================================= */
|
||||||
|
|
||||||
|
/* Das "schwebende" Label */
|
||||||
|
.form-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
border-radius: 4px;
|
||||||
|
/* === WICHTIGE ANPASSUNG FÜR TEXTAREA === */
|
||||||
|
/* Anstatt 'top: 50%', positionieren wir das Label an der ersten Zeile. */
|
||||||
|
/* Der Wert kombiniert das Padding-Top des Inputs mit der halben Schriftgröße. */
|
||||||
|
top: 1.4rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Passt die Hintergrundfarbe des Labels im Dark-Theme an. */
|
||||||
|
:host-context(body.dark-theme) .form-label {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Logik, die das Label nach oben bewegt, bleibt identisch.
|
||||||
|
Das Ziel (top: 0) ist für <input> und <textarea> dasselbe. */
|
||||||
|
.form-input:focus ~ .form-label,
|
||||||
|
.form-input:not(:placeholder-shown) ~ .form-label {
|
||||||
|
top: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sonderregel für Webkit-Browser (Autofill) bleibt ebenfalls relevant. */
|
||||||
|
.form-input:-webkit-autofill ~ .form-label {
|
||||||
|
top: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
FormsModule // Wichtig für [(ngModel)]
|
FormsModule // Wichtig für [(ngModel)]
|
||||||
],
|
],
|
||||||
templateUrl: './form-textarea.component.html',
|
templateUrl: './form-textarea.component.html',
|
||||||
styleUrl: '../form.css',
|
styleUrl: './form-textarea.component.css',
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
:host {
|
||||||
|
display: block; /* Stellt sicher, dass die Komponente Platz einnimmt */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Der neue Haupt-Container, der Label und Schalter anordnet.
|
||||||
|
Wir verwenden Flexbox für eine einfache Kontrolle der Reihenfolge.
|
||||||
|
*/
|
||||||
|
.slide-toggle-container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem; /* Abstand zwischen Label und Schalter */
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Diese Klasse wird dynamisch hinzugefügt, um die Reihenfolge umzukehren. */
|
||||||
|
.slide-toggle-container.label-right {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Das neue Text-Label */
|
||||||
|
.text-label {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
/* Wichtig, damit der Klick auf das Label nicht versehentlich
|
||||||
|
den Toggle-Effekt doppelt auslöst. */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================== */
|
||||||
|
/* Die alten Stile, jetzt für den Schalter-Teil (.slide-toggle-switch)
|
||||||
|
/* ================================================== */
|
||||||
|
|
||||||
|
.slide-toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 50px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0; /* Verhindert, dass der Schalter schrumpft */
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-toggle-input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-toggle-visual-label {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
border-radius: 28px;
|
||||||
|
transition: background-color 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-toggle-visual-label::before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: var(--box-shadow-sm);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-toggle-input:checked + .slide-toggle-visual-label {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-toggle-input:checked + .slide-toggle-visual-label::before {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-toggle-input:disabled ~ .slide-toggle-visual-label {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verändert den Fokus-Stil, um den ganzen Container zu umranden */
|
||||||
|
.slide-toggle-input:focus-visible ~ .slide-toggle-visual-label {
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary-light);
|
||||||
|
}
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
<div class="slide-toggle">
|
<div
|
||||||
|
class="slide-toggle-container"
|
||||||
|
[class.label-right]="labelPosition === 'right'">
|
||||||
|
|
||||||
|
<!-- Das Text-Label (unverändert) -->
|
||||||
|
<label [for]="id" class="text-label" *ngIf="label" (click)="$event.stopPropagation()">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Der Schalter-Teil (unverändert) -->
|
||||||
|
<!-- Der (change)-Listener hier ist die EINZIGE Quelle für die Logik. -->
|
||||||
|
<div class="slide-toggle-switch">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="slide-toggle-input"
|
class="slide-toggle-input"
|
||||||
[id]="id"
|
[id]="id"
|
||||||
[checked]="value"
|
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
(change)="onToggle()">
|
[checked]="value"
|
||||||
<label [for]="id" class="slide-toggle-label"></label>
|
(change)="onToggle()"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
<label [for]="id" class="slide-toggle-visual-label"></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Component, Input, forwardRef } from '@angular/core';
|
import { Component, Input, forwardRef } from '@angular/core';
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-slide-toggle',
|
selector: 'app-slide-toggle',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [ CommonModule ],
|
||||||
CommonModule,
|
|
||||||
FormsModule
|
|
||||||
],
|
|
||||||
templateUrl: './slide-toggle.component.html',
|
templateUrl: './slide-toggle.component.html',
|
||||||
styleUrl: '../form.css',
|
styleUrl: './slide-toggle.component.css',
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
@@ -20,9 +17,16 @@ import { CommonModule } from '@angular/common';
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class SlideToggleComponent implements ControlValueAccessor {
|
export class SlideToggleComponent implements ControlValueAccessor {
|
||||||
@Input() id = `slide-toggle-${Math.random().toString(36).substring(2)}`;
|
// --- NEUE EINGABEWERTE ---
|
||||||
|
/** Der Text, der neben dem Schalter angezeigt wird. */
|
||||||
|
@Input() label: string = '';
|
||||||
|
/** Position des Labels: 'left' (Standard) oder 'right'. */
|
||||||
|
@Input() labelPosition: 'left' | 'right' = 'left';
|
||||||
|
|
||||||
|
// --- BESTEHENDE LOGIK ---
|
||||||
|
// Jede Instanz bekommt eine eindeutige ID für die for/id-Verknüpfung.
|
||||||
|
id = `slide-toggle-${Math.random().toString(36).substring(2)}`;
|
||||||
|
|
||||||
// Interne Logik für ControlValueAccessor
|
|
||||||
value = false;
|
value = false;
|
||||||
onChange: (value: any) => void = () => {};
|
onChange: (value: any) => void = () => {};
|
||||||
onTouched: () => void = () => {};
|
onTouched: () => void = () => {};
|
||||||
@@ -33,11 +37,6 @@ export class SlideToggleComponent implements ControlValueAccessor {
|
|||||||
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
||||||
setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; }
|
setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; }
|
||||||
|
|
||||||
// +++ HIER IST DIE FEHLENDE METHODE +++
|
|
||||||
/**
|
|
||||||
* Wird aufgerufen, wenn der Benutzer auf den Schalter klickt.
|
|
||||||
* Aktualisiert den internen Wert und benachrichtigt Angular Forms.
|
|
||||||
*/
|
|
||||||
onToggle(): void {
|
onToggle(): void {
|
||||||
if (!this.disabled) {
|
if (!this.disabled) {
|
||||||
this.value = !this.value;
|
this.value = !this.value;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ body.dark-theme {
|
|||||||
--color-text: #ecf0f1;
|
--color-text: #ecf0f1;
|
||||||
--color-text-light: #95a5a6;
|
--color-text-light: #95a5a6;
|
||||||
--color-body-bg: #1a202c;
|
--color-body-bg: #1a202c;
|
||||||
|
--color-body-bg-hover: rgb(34, 41, 56);
|
||||||
--color-surface: #2d3748;
|
--color-surface: #2d3748;
|
||||||
--color-border: #4a5568;
|
--color-border: #4a5568;
|
||||||
}
|
}
|
||||||
@@ -68,16 +69,10 @@ body {
|
|||||||
color var(--transition-speed);
|
color var(--transition-speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Globale Klasse, um das Scrollen zu unterbinden, wenn ein Overlay offen ist */
|
|
||||||
body.no-scroll {
|
body.no-scroll {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =================================================================================
|
|
||||||
* 3. GLOBALE HELFER- & UTILITY-KLASSEN
|
|
||||||
* Diese Klassen können überall in der Anwendung verwendet werden.
|
|
||||||
* ================================================================================= */
|
|
||||||
|
|
||||||
/* -- Textausrichtung -- */
|
/* -- Textausrichtung -- */
|
||||||
.text-right {
|
.text-right {
|
||||||
text-align: right !important;
|
text-align: right !important;
|
||||||
@@ -89,13 +84,18 @@ body.no-scroll {
|
|||||||
text-align: center !important;
|
text-align: center !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Dashboard Grid Layout (da es ein grundlegendes Layout-Muster ist) -- */
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-col-span-1 {
|
.grid-col-span-1 {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,6 @@ body.no-scroll {
|
|||||||
grid-column: span 4;
|
grid-column: span 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- Tooltip (muss global sein, da es auf jedes Element angewendet werden kann) -- */
|
|
||||||
[data-tooltip] {
|
[data-tooltip] {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -140,9 +139,6 @@ body.no-scroll {
|
|||||||
transform: translateX(-50%) translateY(-12px);
|
transform: translateX(-50%) translateY(-12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =================================================================================
|
|
||||||
* 4. GLOBALE RESPONSIVITÄT
|
|
||||||
* ================================================================================= */
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
@@ -152,5 +148,7 @@ body.no-scroll {
|
|||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
/* Stile für .main-content etc. gehören in die Layout-Komponente (z.B. demo2.component.css) */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================================================================== */
|
||||||
|
/* ========================================================================================== */
|
||||||
|
|||||||
Reference in New Issue
Block a user