UI improvments

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent c9dbe82b79
commit 0ce33cc9d6
106 files changed
+72934 -1675

No files matched your search

+57 -38
View File
@@ -9,7 +9,7 @@
</mat-expansion-panel-header>
<div class="priority-statement">
<mat-icon color="primary">info</mat-icon>
<mat-icon aria-hidden="true">info_outline</mat-icon>
<span>{{ tr().rules.priorityInfo }}</span>
</div>
@@ -18,15 +18,22 @@
<div
class="rule-item"
cdkDrag
cdkDragLockAxis="y"
[cdkDragStartDelay]="{ touch: 180, mouse: 0 }"
[cdkDragDisabled]="editingRuleId() === rule.id"
[class.editing]="editingRuleId() === rule.id"
>
@if (editingRuleId() !== rule.id) {
<div class="rule-drag-grip">
<mat-icon class="drag-handle-icon">drag_handle</mat-icon>
</div>
<div class="rule-display">
<div class="rule-header">
<div
class="rule-drag-grip"
cdkDragHandle
[title]="tr().rules.dragRule"
aria-hidden="true"
>
<mat-icon>drag_indicator</mat-icon>
</div>
<mat-slide-toggle
[checked]="rule.enabled"
(change)="toggleRule(rule.id)"
@@ -37,41 +44,53 @@
{{ rule.name }}</span
>
</mat-slide-toggle>
<div class="rule-actions">
<button
mat-icon-button
[disabled]="i === 0"
[aria-label]="tr().rules.moveRuleUp"
(click)="moveRule(i, -1, $event)"
>
<mat-icon>keyboard_arrow_up</mat-icon>
</button>
<button
mat-icon-button
[disabled]="i === rules.length - 1"
[aria-label]="tr().rules.moveRuleDown"
(click)="moveRule(i, 1, $event)"
>
<mat-icon>keyboard_arrow_down</mat-icon>
</button>
<button
mat-icon-button
[aria-label]="tr().rules.editRule"
(click)="startEdit(rule, $event)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
[aria-label]="tr().rules.deleteRule"
(click)="deleteRule(rule.id, $event)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
<div class="rule-summary" [innerHTML]="getRuleSummary(rule)"></div>
<div class="rule-summary">
<span
class="rule-force"
[class.obligation]="rule.force === 'obligation'"
>
{{
rule.force === 'obligation'
? tr().rules.obligation
: tr().rules.preference
}}
</span>
<span [innerHTML]="getRuleSummary(rule)"></span>
</div>
<div class="rule-actions">
<button
mat-icon-button
[disabled]="i === 0"
[aria-label]="tr().rules.moveRuleUp"
(click)="moveRule(i, -1, $event)"
>
<mat-icon aria-hidden="true">keyboard_arrow_up</mat-icon>
</button>
<button
mat-icon-button
[disabled]="i === rules.length - 1"
[aria-label]="tr().rules.moveRuleDown"
(click)="moveRule(i, 1, $event)"
>
<mat-icon aria-hidden="true">keyboard_arrow_down</mat-icon>
</button>
<button
mat-icon-button
[aria-label]="tr().rules.editRule"
(click)="startEdit(rule, $event)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
[aria-label]="tr().rules.deleteRule"
(click)="deleteRule(rule.id, $event)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
} @else {
<div class="rule-editor">
+116 -61
View File
@@ -1,9 +1,31 @@
.rules-panel-container,
:host {
min-width: 0;
}
:host {
display: block;
}
.rules-panel-container {
margin: 0;
box-shadow: none;
border: 1px solid var(--ui-border);
background: var(--ui-card-bg);
border-radius: var(--ui-radius-md, 0.75rem);
overflow: hidden;
container-type: inline-size;
mat-expansion-panel-header {
padding-inline: 0.75rem;
min-height: 3.5rem;
height: auto;
padding-block: 0.625rem;
}
mat-panel-title {
margin: 0 0.5rem 0 0;
}
mat-panel-title {
display: flex;
flex-wrap: wrap;
@@ -18,6 +40,7 @@
border-radius: 999px;
color: var(--mat-sys-on-primary-container, #001b3f);
font-size: 0.75rem;
white-space: nowrap;
font-weight: 600;
line-height: 1.5;
}
@@ -37,20 +60,16 @@
}
.rule-item {
container: rule-item / inline-size;
font: var(--mat-sys-body-medium);
letter-spacing: var(--mat-sys-body-medium-tracking);
border: 1px solid var(--mat-sys-outline-variant, #c7c7d0);
border-radius: var(--ui-radius-sm, 0.5rem);
transition:
border-color 200ms ease,
background-color 200ms ease;
background: var(--mat-sys-surface-container, #f1f1f5);
&:not(.editing) {
cursor: grab;
&:active {
cursor: grabbing;
}
}
background: var(--ui-panel-bg);
color: var(--mat-sys-on-surface);
&:hover:not(.editing) {
border-color: var(--mat-sys-outline, #777680);
@@ -63,15 +82,18 @@
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 8px;
box-shadow:
0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
border-color: var(--mat-sys-primary);
background: var(--ui-card-bg);
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 18%);
cursor: grabbing;
}
.cdk-drag-placeholder {
opacity: 0;
border: 1px dashed var(--mat-sys-primary);
background: var(--mat-sys-primary-container);
> * {
visibility: hidden;
}
}
.cdk-drag-animating {
@@ -83,90 +105,116 @@
}
.rule-display {
padding: 16px;
display: grid;
gap: 0.75rem;
padding: 0.75rem;
}
.priority-statement {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--mat-sys-primary-container, #d7e3ff);
color: var(--mat-sys-on-primary-container, #001b3f);
border-radius: 8px;
font-size: 0.95rem;
margin-top: 8px;
margin-bottom: 8px;
align-items: flex-start;
gap: 0.5rem;
color: var(--ui-text-secondary);
font-size: 0.875rem;
line-height: 1.5;
margin-block: 0.25rem 0.75rem;
mat-icon {
color: var(--mat-sys-primary, #005cbb);
flex-shrink: 0;
}
}
.rule-drag-grip {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
padding-top: 8px;
margin-bottom: -8px;
color: var(--mat-sys-outline, #777680);
opacity: 0.6;
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius-sm);
color: var(--ui-text-secondary);
cursor: grab;
transition: opacity 0.2s;
transition: background-color 150ms ease;
.rule-item:active & {
&:active,
.cdk-drag-preview & {
cursor: grabbing;
}
&:hover {
opacity: 1;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
}
.rule-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
display: grid;
grid-template-columns: 2.75rem minmax(0, 1fr);
align-items: center;
margin-bottom: 8px;
gap: 0.75rem;
mat-slide-toggle {
min-width: 0;
}
.rule-name {
font-weight: 600;
font-size: 1.05rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
font-size: 1rem;
line-height: 1.4;
overflow-wrap: anywhere;
.rule-number {
color: var(--mat-sys-primary, #005cbb);
font-weight: 700;
}
}
}
.rule-actions {
display: flex;
flex-wrap: wrap;
gap: 0;
.rule-actions {
display: flex;
flex-wrap: wrap;
border-top: 1px solid var(--ui-border);
padding-top: 0.5rem;
button {
width: 2.75rem;
height: 2.75rem;
padding: 0.625rem;
}
button:nth-child(3) {
margin-inline-start: auto;
}
}
.rule-summary {
font-size: 0.95rem;
font-size: 0.875rem;
line-height: 1.5;
padding-left: 48px; /* Align with toggle switch text */
strong {
font-weight: 600;
}
}
.rule-force {
display: inline-block;
margin-inline-end: 0.375rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
vertical-align: baseline;
white-space: nowrap;
&.obligation {
background: var(--ui-warning-bg);
color: var(--ui-warning-text);
}
}
.rule-editor {
padding: 20px;
padding: clamp(0.75rem, 3cqi, 1.25rem);
display: flex;
flex-direction: column;
gap: 16px;
@@ -186,23 +234,27 @@
align-items: center;
.action-text {
flex-basis: 100%;
font-weight: 500;
margin-right: 4px;
}
mat-form-field {
flex: 1 1 11rem;
min-width: 0;
max-width: 100%;
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.small-input {
width: 100px;
flex: 0 1 6.25rem;
}
}
.full-width {
width: 100%;
min-width: 0;
}
.editor-actions {
@@ -226,17 +278,20 @@
}
button {
border-radius: 24px;
border-radius: var(--ui-radius-sm);
min-height: 2.75rem;
height: auto;
padding-block: 0.625rem;
width: 100%;
}
}
@container (max-width: 30rem) {
.rule-summary {
padding-left: 0;
@container rule-item (max-width: 18rem) {
.rule-display {
padding: 0.5rem;
}
.rule-header {
align-items: flex-start;
gap: 0.5rem;
}
}
@@ -3,6 +3,10 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { RulesPanel } from './rules-panel';
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { By } from '@angular/platform-browser';
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { vi } from 'vitest';
describe('RulesPanel', () => {
let component: RulesPanel;
@@ -111,4 +115,31 @@ describe('RulesPanel', () => {
component.moveRule(1, -1, { stopPropagation: () => {} } as Event);
expect(component.rules[0].id).toBe(secondRuleId);
});
it('keeps cancelled drops unchanged and announces the position after a completed drop', () => {
const initialIds = component.rules.map((rule) => rule.id);
const movedName = component.rules[0].name;
const announce = vi.spyOn(TestBed.inject(LiveAnnouncer), 'announce').mockResolvedValue();
const list = fixture.debugElement
.query(By.directive(CdkDropList))
.injector.get(CdkDropList);
const drop = {
previousIndex: 0,
currentIndex: 1,
isPointerOverContainer: false,
} as CdkDragDrop<unknown>;
list.dropped.emit(drop);
expect(component.rules.map((rule) => rule.id)).toEqual(initialIds);
expect(announce).not.toHaveBeenCalled();
list.dropped.emit({ ...drop, isPointerOverContainer: true });
expect(component.rules.map((rule) => rule.id)).toEqual([...initialIds].reverse());
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 2.`);
// CDK restores the original DOM before emitting dropped. The new order must
// be rendered before this callback returns, without waiting for another frame.
expect(fixture.nativeElement.querySelectorAll('.rule-name')[1].textContent).toContain(
movedName,
);
expect(fixture.nativeElement.querySelectorAll('.rule-number')[1].textContent).toBe('2.');
});
});
+30 -44
View File
@@ -1,9 +1,9 @@
import {
Component,
ChangeDetectionStrategy,
ChangeDetectorRef,
inject,
signal,
Injectable,
effect,
} from '@angular/core';
import {
@@ -25,37 +25,11 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { FormsModule } from '@angular/forms';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
NativeDateAdapter,
} from '@angular/material/core';
import { CdkDragDrop, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
@Injectable()
class CustomDateAdapter extends NativeDateAdapter {
override format(date: Date, displayFormat: unknown): string {
if (displayFormat === 'input') {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return `${day}/${month}/${year}`;
}
return super.format(date, displayFormat as object);
}
}
const MY_DATE_FORMATS = {
parse: { dateInput: 'input' },
display: {
dateInput: 'input',
monthYearLabel: { year: 'numeric', month: 'short' },
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
monthYearA11yLabel: { year: 'numeric', month: 'long' },
},
};
import { DateAdapter } from '@angular/material/core';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-rules-panel',
@@ -71,20 +45,19 @@ const MY_DATE_FORMATS = {
FormsModule,
CdkDropList,
CdkDrag,
CdkDragHandle,
],
templateUrl: './rules-panel.html',
styleUrl: './rules-panel.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: DateAdapter, useClass: CustomDateAdapter },
{ provide: MAT_DATE_LOCALE, useValue: 'en-GB' },
{ provide: MAT_DATE_FORMATS, useValue: MY_DATE_FORMATS },
],
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class RulesPanel {
public ruleService = inject(RuleService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject(DateAdapter);
private readonly liveAnnouncer = inject(LiveAnnouncer);
private readonly changeDetector = inject(ChangeDetectorRef);
get tr() {
return this.translationService.tr;
@@ -145,16 +118,30 @@ export class RulesPanel {
}
public drop(event: CdkDragDrop<VacationRule[]>) {
if (event.previousIndex !== event.currentIndex) {
this.ruleService.reorderRules(event.previousIndex, event.currentIndex);
if (event.isPointerOverContainer && event.previousIndex !== event.currentIndex) {
this.reorderRule(event.previousIndex, event.currentIndex);
}
// CDK restores the original DOM before this callback. Commit the final order
// before the next paint so zoneless rendering doesn't flash the old position.
this.changeDetector.detectChanges();
}
public moveRule(index: number, offset: -1 | 1, event: Event) {
event.stopPropagation();
const destination = index + offset;
if (destination >= 0 && destination < this.rules.length)
this.ruleService.reorderRules(index, destination);
this.reorderRule(index, destination);
}
private reorderRule(from: number, to: number) {
const rule = this.rules[from];
this.ruleService.reorderRules(from, to);
void this.liveAnnouncer.announce(
this.tr()
.rules.ruleMoved.replace('{name}', rule.name)
.replace('{position}', String(to + 1))
.replace('{count}', String(this.rules.length)),
);
}
public toggleRule(id: string) {
@@ -229,7 +216,6 @@ export class RulesPanel {
public getRuleSummary(rule: VacationRule): string {
const tr = this.tr();
const forceLabel = rule.force === 'obligation' ? tr.rules.obligation : tr.rules.preference;
if (rule.type === RuleType.IF_THEN) {
const ifThen = rule as IfThenRule;
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
@@ -241,13 +227,13 @@ export class RulesPanel {
.toLowerCase();
const rewardTypeLabel = this.getDayTypeLabel(ifThen.rewardType);
return `[${forceLabel}] ${tr.rules.ifThenTemplate
return tr.rules.ifThenTemplate
.replace('{day}', dayLabel)
.replace('{placeDays}', String(ifThen.placeDays))
.replace('{placeType}', placeTypeLabel)
.replace('{placement}', placementLabel)
.replace('{rewardDays}', String(ifThen.rewardDays))
.replace('{rewardType}', rewardTypeLabel)}`;
.replace('{rewardType}', rewardTypeLabel);
} else {
const timeWindow = rule as TimeWindowRule;
const placeTypeLabel = this.getDayTypeLabel(timeWindow.placeType);
@@ -258,11 +244,11 @@ export class RulesPanel {
this.translationService.locale(),
);
return `[${forceLabel}] ${tr.rules.timeWindowTemplate
return tr.rules.timeWindowTemplate
.replace('{placeDays}', String(timeWindow.placeDays))
.replace('{placeType}', placeTypeLabel)
.replace('{startDate}', startDateStr)
.replace('{endDate}', endDateStr)}`;
.replace('{endDate}', endDateStr);
}
}
}