From 6c52ee349d987eb19733746c163011ee5de55ea1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 30 Jan 2026 20:36:36 +0100 Subject: [PATCH] feat: add resizable directive and styles for dynamic element resizing --- libs/ui/components/src/index.ts | 1 + .../src/lib/resizable/resizable.directive.ts | 297 ++++++++++++++++++ .../src/lib/resizable/resizable.scss | 184 +++++++++++ 3 files changed, 482 insertions(+) create mode 100644 libs/ui/components/src/lib/resizable/resizable.directive.ts create mode 100644 libs/ui/components/src/lib/resizable/resizable.scss diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 6426219e9..f990547b6 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/resizable/resizable.directive.ts b/libs/ui/components/src/lib/resizable/resizable.directive.ts new file mode 100644 index 000000000..058db07f9 --- /dev/null +++ b/libs/ui/components/src/lib/resizable/resizable.directive.ts @@ -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 + *
+ * Content here + *
+ * ``` + * + * 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(200); + + /** Maximum width in pixels */ + readonly maxWidth = input(600); + + /** LocalStorage key for persisting width. If not set, width won't be persisted. */ + readonly storageKey = input(''); + + /** Default width to use when no stored value exists */ + readonly defaultWidth = input(400); + + /** Position of the resize handle: 'right' or 'left' */ + readonly handlePosition = input<'right' | 'left'>('right'); + + /** Emits when width changes during resize */ + readonly widthChange = output(); + + /** Emits when resize starts */ + readonly resizeStart = output(); + + /** Emits when resize ends */ + readonly resizeEnd = output(); + + 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, + 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()); + } + } +} diff --git a/libs/ui/components/src/lib/resizable/resizable.scss b/libs/ui/components/src/lib/resizable/resizable.scss new file mode 100644 index 000000000..1ae5be5a0 --- /dev/null +++ b/libs/ui/components/src/lib/resizable/resizable.scss @@ -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); + } + } + } +}