feat: add resizable directive and styles for dynamic element resizing

This commit is contained in:
4gray committed 2026-01-30 20:36:36 +01:00
1 parent 93e1d098d5
commit 6c52ee349d
3 files changed
+482

No files matched your search

+1
View File
@@ -19,3 +19,4 @@ export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/video-player/sidebar/sidebar.component';
export * from './lib/video-player/toolbar/toolbar.component';
export * from './lib/vjs-player/vjs-player.component';
export * from './lib/resizable/resizable.directive';
@@ -0,0 +1,297 @@
import {
Directive,
ElementRef,
Renderer2,
input,
output,
OnInit,
OnDestroy,
effect,
signal,
AfterViewInit,
} from '@angular/core';
/**
* ResizableDirective - Makes an element horizontally resizable by dragging its edge.
*
* Usage:
* ```html
* <div appResizable [minWidth]="200" [maxWidth]="600" [storageKey]="'sidebar-width'">
* Content here
* </div>
* ```
*
* Features:
* - Smooth drag handle with visual feedback
* - Persists width to localStorage
* - Respects min/max width constraints
* - Emits width changes for reactive updates
*/
@Directive({
selector: '[appResizable]',
standalone: true,
})
export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
/** Minimum width in pixels */
readonly minWidth = input<number>(200);
/** Maximum width in pixels */
readonly maxWidth = input<number>(600);
/** LocalStorage key for persisting width. If not set, width won't be persisted. */
readonly storageKey = input<string>('');
/** Default width to use when no stored value exists */
readonly defaultWidth = input<number>(400);
/** Position of the resize handle: 'right' or 'left' */
readonly handlePosition = input<'right' | 'left'>('right');
/** Emits when width changes during resize */
readonly widthChange = output<number>();
/** Emits when resize starts */
readonly resizeStart = output<void>();
/** Emits when resize ends */
readonly resizeEnd = output<number>();
private handleElement: HTMLElement | null = null;
private isResizing = signal(false);
private startX = 0;
private startWidth = 0;
private currentWidth = signal(0);
// Event listener references for cleanup
private boundMouseMove: ((e: MouseEvent) => void) | null = null;
private boundMouseUp: ((e: MouseEvent) => void) | null = null;
private boundTouchMove: ((e: TouchEvent) => void) | null = null;
private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
constructor(
private readonly el: ElementRef<HTMLElement>,
private readonly renderer: Renderer2
) {
// Apply resizing class when dragging
effect(() => {
if (this.isResizing()) {
this.renderer.addClass(document.body, 'resizing-active');
this.renderer.addClass(this.el.nativeElement, 'is-resizing');
} else {
this.renderer.removeClass(document.body, 'resizing-active');
this.renderer.removeClass(this.el.nativeElement, 'is-resizing');
}
});
}
ngOnInit(): void {
// Load persisted width FIRST to avoid flash of default width
this.loadPersistedWidth();
this.setupStyles();
}
ngAfterViewInit(): void {
this.createResizeHandle();
}
ngOnDestroy(): void {
this.removeGlobalListeners();
if (this.handleElement) {
this.handleElement.remove();
}
}
private setupStyles(): void {
const el = this.el.nativeElement;
// Ensure the element has position relative for handle positioning
const computedStyle = window.getComputedStyle(el);
if (computedStyle.position === 'static') {
this.renderer.setStyle(el, 'position', 'relative');
}
// Add transition for smooth width changes (only when not dragging)
this.renderer.addClass(el, 'resizable-element');
}
private createResizeHandle(): void {
const handle = this.renderer.createElement('div');
this.handleElement = handle;
// Set up handle classes based on position
this.renderer.addClass(handle, 'resize-handle');
this.renderer.addClass(
handle,
`resize-handle--${this.handlePosition()}`
);
// Create inner visual elements for the handle
const handleLine = this.renderer.createElement('div');
this.renderer.addClass(handleLine, 'resize-handle__line');
this.renderer.appendChild(handle, handleLine);
// Create grip dots
const gripDots = this.renderer.createElement('div');
this.renderer.addClass(gripDots, 'resize-handle__grip');
for (let i = 0; i < 3; i++) {
const dot = this.renderer.createElement('span');
this.renderer.appendChild(gripDots, dot);
}
this.renderer.appendChild(handle, gripDots);
// Mouse events
this.renderer.listen(handle, 'mousedown', (e: MouseEvent) =>
this.onMouseDown(e)
);
// Touch events for mobile support
this.renderer.listen(handle, 'touchstart', (e: TouchEvent) =>
this.onTouchStart(e)
);
// Append handle to element
this.renderer.appendChild(this.el.nativeElement, handle);
}
private loadPersistedWidth(): void {
const key = this.storageKey();
let widthToApply = this.defaultWidth();
if (key) {
const stored = localStorage.getItem(key);
if (stored) {
const width = parseInt(stored, 10);
if (!isNaN(width)) {
widthToApply = this.clampWidth(width);
}
}
}
// Apply width immediately without transition to avoid flash
this.setWidthImmediate(widthToApply);
}
/** Set width immediately without CSS transition */
private setWidthImmediate(width: number): void {
this.currentWidth.set(width);
// Temporarily disable transition for immediate application
this.renderer.setStyle(this.el.nativeElement, 'transition', 'none');
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
// Re-enable transition after a frame
requestAnimationFrame(() => {
this.renderer.removeStyle(this.el.nativeElement, 'transition');
});
}
private onMouseDown(e: MouseEvent): void {
e.preventDefault();
e.stopPropagation();
this.startResize(e.clientX);
this.boundMouseMove = (event: MouseEvent) => this.onMouseMove(event);
this.boundMouseUp = (event: MouseEvent) => this.onMouseUp(event);
document.addEventListener('mousemove', this.boundMouseMove);
document.addEventListener('mouseup', this.boundMouseUp);
}
private onTouchStart(e: TouchEvent): void {
if (e.touches.length !== 1) return;
e.preventDefault();
e.stopPropagation();
this.startResize(e.touches[0].clientX);
this.boundTouchMove = (event: TouchEvent) => this.onTouchMove(event);
this.boundTouchEnd = (event: TouchEvent) => this.onTouchEnd(event);
document.addEventListener('touchmove', this.boundTouchMove, {
passive: false,
});
document.addEventListener('touchend', this.boundTouchEnd);
}
private startResize(clientX: number): void {
this.isResizing.set(true);
this.startX = clientX;
this.startWidth = this.el.nativeElement.offsetWidth;
this.resizeStart.emit();
}
private onMouseMove(e: MouseEvent): void {
if (!this.isResizing()) return;
this.resize(e.clientX);
}
private onTouchMove(e: TouchEvent): void {
if (!this.isResizing() || e.touches.length !== 1) return;
e.preventDefault();
this.resize(e.touches[0].clientX);
}
private resize(clientX: number): void {
const delta =
this.handlePosition() === 'right'
? clientX - this.startX
: this.startX - clientX;
const newWidth = this.clampWidth(this.startWidth + delta);
this.setWidth(newWidth);
this.widthChange.emit(newWidth);
}
private onMouseUp(_e: MouseEvent): void {
this.endResize();
}
private onTouchEnd(_e: TouchEvent): void {
this.endResize();
}
private endResize(): void {
if (!this.isResizing()) return;
this.isResizing.set(false);
this.removeGlobalListeners();
this.persistWidth();
this.resizeEnd.emit(this.currentWidth());
}
private removeGlobalListeners(): void {
if (this.boundMouseMove) {
document.removeEventListener('mousemove', this.boundMouseMove);
this.boundMouseMove = null;
}
if (this.boundMouseUp) {
document.removeEventListener('mouseup', this.boundMouseUp);
this.boundMouseUp = null;
}
if (this.boundTouchMove) {
document.removeEventListener('touchmove', this.boundTouchMove);
this.boundTouchMove = null;
}
if (this.boundTouchEnd) {
document.removeEventListener('touchend', this.boundTouchEnd);
this.boundTouchEnd = null;
}
}
private clampWidth(width: number): number {
return Math.max(this.minWidth(), Math.min(this.maxWidth(), width));
}
private setWidth(width: number): void {
this.currentWidth.set(width);
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
}
private persistWidth(): void {
const key = this.storageKey();
if (key) {
localStorage.setItem(key, this.currentWidth().toString());
}
}
}
@@ -0,0 +1,184 @@
/**
* Resizable Directive Styles
*
* A refined, subtle resize handle that provides clear affordance
* without being visually intrusive. Uses a vertical grip pattern
* that becomes more prominent on hover/active states.
*/
// Prevent text selection during resize
body.resizing-active {
user-select: none !important;
cursor: col-resize !important;
* {
cursor: col-resize !important;
}
}
// Base element styles
.resizable-element {
// Smooth transition when not actively resizing
&:not(.is-resizing) {
transition: width 0.15s ease-out;
}
&.is-resizing {
transition: none;
}
}
// Resize handle base styles
.resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 12px;
z-index: 100;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
// Visual feedback on hover/active
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 4px;
background: transparent;
transition: background-color 0.2s ease;
border-radius: 2px;
}
&:hover::before,
&:active::before {
background: rgba(255, 255, 255, 0.08);
}
&:active::before {
background: rgba(255, 255, 255, 0.12);
}
// Position variants
&--right {
right: -6px;
&::before {
left: 50%;
transform: translateX(-50%);
}
}
&--left {
left: -6px;
&::before {
right: 50%;
transform: translateX(50%);
}
}
// The main visible line
&__line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.12);
transition: all 0.2s ease;
}
&:hover &__line {
width: 2px;
background: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
}
&:active &__line {
width: 2px;
background: var(--mdc-theme-primary, #6366f1);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
}
// Grip dots indicator
&__grip {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transition: all 0.15s ease;
}
}
&:hover &__grip {
opacity: 1;
span {
background: rgba(255, 255, 255, 0.6);
}
}
&:active &__grip {
opacity: 1;
span {
background: var(--mdc-theme-primary, #6366f1);
transform: scale(1.2);
}
}
}
// Light theme adjustments (if needed)
.light-theme {
.resize-handle {
&::before {
background: transparent;
}
&:hover::before,
&:active::before {
background: rgba(0, 0, 0, 0.04);
}
&:active::before {
background: rgba(0, 0, 0, 0.08);
}
&__line {
background: rgba(0, 0, 0, 0.12);
}
&:hover &__line {
background: rgba(0, 0, 0, 0.25);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.05);
}
&:active &__line {
background: var(--mdc-theme-primary, #6366f1);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.2);
}
&__grip {
span {
background: rgba(0, 0, 0, 0.3);
}
}
&:hover &__grip {
span {
background: rgba(0, 0, 0, 0.5);
}
}
}
}