Merge branch 'master' into develop

This commit is contained in:
Lorenz Hilpert
2025-10-16 14:56:46 +02:00
26 changed files with 421 additions and 124 deletions

View File

@@ -1,3 +1,4 @@
export * from './lib/inject-restore-scroll-position';
export * from './lib/provide-scroll-position-restoration';
export * from './lib/store-scroll-position';
export * from './lib/scroll-top-button.component';

View File

@@ -0,0 +1,130 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture } from '@angular/core/testing';
import { ScrollTopButtonComponent } from './scroll-top-button.component';
describe('ScrollTopButtonComponent (happy path)', () => {
let fixture: ComponentFixture<ScrollTopButtonComponent>;
let component: ScrollTopButtonComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScrollTopButtonComponent],
}).compileComponents();
fixture = TestBed.createComponent(ScrollTopButtonComponent);
component = fixture.componentInstance;
// Polyfill / Reset matchMedia für jedes Test-Setup
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: false, // Default: keine reduzierte Animation
media: query,
onchange: null,
addListener: jest.fn(), // deprecated, aber Angular / libs könnten darauf zugreifen
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
it('should create', () => {
// Act
fixture.detectChanges();
// Assert
expect(component).toBeTruthy();
});
it('should call scrollTo with smooth when prefers-reduced-motion is false', () => {
// Arrange
const targetEl = document.createElement('div');
(targetEl as any).scrollTo = jest.fn();
fixture.componentRef.setInput('target', targetEl);
// matchMedia default (set in beforeEach) returns matches: false
// Act
component.scrollTop();
// Assert
expect((targetEl as any).scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'smooth',
});
});
it('should call scrollTo with auto when prefers-reduced-motion is true', () => {
// Arrange
(window.matchMedia as jest.Mock).mockImplementationOnce(
(query: string) => ({
matches: true, // reduzierte Bewegungen bevorzugt
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}),
);
const targetEl = document.createElement('div');
(targetEl as any).scrollTo = jest.fn();
fixture.componentRef.setInput('target', targetEl);
// Act
component.scrollTop();
// Assert
expect((targetEl as any).scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'auto',
});
});
it('should render button when target element scrolled down', () => {
// Arrange
jest.useFakeTimers();
const targetEl = document.createElement('div');
(targetEl as any).scrollTo = jest.fn();
targetEl.scrollTop = 150; // > 0 so truthy
fixture.componentRef.setInput('target', targetEl);
// Act
fixture.detectChanges();
targetEl.dispatchEvent(new Event('scroll'));
jest.advanceTimersByTime(20); // allow debounceTime(10) to elapse
fixture.detectChanges();
// Assert
const button = fixture.nativeElement.querySelector(
'[data-what="scroll-top-button"]',
);
expect(button).not.toBeNull();
jest.useRealTimers();
});
it('should not render button when target element at top (scrollTop = 0)', () => {
// Arrange
jest.useFakeTimers();
const targetEl = document.createElement('div');
(targetEl as any).scrollTo = jest.fn();
targetEl.scrollTop = 0; // top position
fixture.componentRef.setInput('target', targetEl);
// Act
fixture.detectChanges();
targetEl.dispatchEvent(new Event('scroll'));
jest.advanceTimersByTime(20);
fixture.detectChanges();
// Assert
const button = fixture.nativeElement.querySelector(
'[data-what="scroll-top-button"]',
);
expect(button).toBeNull();
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,75 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
ViewEncapsulation,
} from '@angular/core';
import { provideIcons } from '@ng-icons/core';
import { isaSortByUpMedium } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, fromEvent, switchMap } from 'rxjs';
@Component({
selector: 'utils-scroll-top-button',
imports: [IconButtonComponent],
providers: [provideIcons({ isaSortByUpMedium })],
template: `
@if (display()) {
<button
uiIconButton
aria-label="Scroll to top"
type="button"
color="tertiary"
size="large"
data-what="scroll-top-button"
name="isaSortByUpMedium"
(click)="scrollTop()"
></button>
}
`,
host: {
'[class]': '["utils-scroll-top-button"]',
},
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScrollTopButtonComponent {
/** The scroll target, either `window` or a specific element. */
target = input<Window | HTMLElement>(window);
/** Whether the target is an `HTMLElement`. */
isTargetElement = computed(() => this.target() instanceof HTMLElement);
/** The scroll event signal. */
scrollEvent = toSignal(
toObservable(this.target).pipe(
switchMap((target) => fromEvent(target, 'scroll').pipe(debounceTime(16))),
),
);
/** Whether to display the button. */
display = computed(() => {
this.scrollEvent();
const target = this.target();
if (target instanceof HTMLElement) {
return target.scrollTop;
}
return target.scrollY;
});
/** Scrolls to the top of the page. */
scrollTop() {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches; // Anforderung im Ticket
this.target().scrollTo({
top: 0,
behavior: prefersReducedMotion ? 'auto' : 'smooth',
});
}
}