Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion projects/angular-split/src/lib/angular-split-config.token.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { InjectionToken, Provider, inject } from '@angular/core'
import { InjectionToken, type Provider, type Type, inject } from '@angular/core'
import type { SplitDir, SplitDirection, SplitUnit } from './models'
import type { SplitGutterComponent } from './gutter/split-gutter-component'

export interface AngularSplitDefaultOptions {
dir: SplitDir
Expand All @@ -12,6 +13,7 @@ export interface AngularSplitDefaultOptions {
restrictMove: boolean
unit: SplitUnit
useTransition: boolean
gutterComponent: Type<SplitGutterComponent> | undefined
}

const defaultOptions: AngularSplitDefaultOptions = {
Expand All @@ -25,6 +27,7 @@ const defaultOptions: AngularSplitDefaultOptions = {
restrictMove: false,
unit: 'percent',
useTransition: false,
gutterComponent: undefined,
}

export const ANGULAR_SPLIT_DEFAULT_OPTIONS = new InjectionToken<AngularSplitDefaultOptions>(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { InputSignal } from '@angular/core'
import type { SplitGutterContext } from './split-gutter-context'

export interface SplitGutterComponent {
context: InputSignal<SplitGutterContext>
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SplitGutterComponent currently requires context: InputSignal<SplitGutterContext>, which forces consumers to use the signal-based input() API to satisfy gutterComponent: Type<SplitGutterComponent>. Since *ngComponentOutlet will also work with a classic @Input() context: SplitGutterContext, consider widening the contract (e.g., context: SplitGutterContext | InputSignal<SplitGutterContext>) so consumers aren’t artificially constrained by types.

Suggested change
context: InputSignal<SplitGutterContext>
context: SplitGutterContext | InputSignal<SplitGutterContext>

Copilot uses AI. Check for mistakes.
}
33 changes: 33 additions & 0 deletions projects/angular-split/src/lib/gutter/split-gutter-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { SplitAreaComponent } from '../split-area/split-area.component'

export interface SplitGutterContext {
/**
* The area before the gutter.
* In RTL the right area and in LTR the left area
*/
areaBefore: SplitAreaComponent
/**
* The area after the gutter.
* In RTL the left area and in LTR the right area
*/
areaAfter: SplitAreaComponent
/**
* The absolute number of the gutter based on direction (RTL and LTR).
* First gutter is 1, second is 2, etc...
*/
gutterNum: number
/**
* Whether this is the first gutter.
* In RTL the most right area and in LTR the most left area
*/
first: boolean
/**
* Whether this is the last gutter.
* In RTL the most left area and in LTR the most right area
*/
last: boolean
/**
* Whether the gutter is being dragged now
*/
isDragged: boolean
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,45 @@
import { Directive, OnDestroy, ElementRef, inject } from '@angular/core'
import { SplitGutterDirective } from './split-gutter.directive'
import { Directive, OnDestroy, ElementRef, inject, computed, input, booleanAttribute } from '@angular/core'
import { GUTTER_NUM_TOKEN } from './gutter-num-token'
import { SplitGuttersManagerService } from './split-gutters-manager.service'
import { SplitComponent } from '../split/split.component'
import { assertUnreachable } from '../utils'

@Directive({
selector: '[asSplitGutterDragHandle]',
host: {
'[style.cursor]': `cursor()`,
},
})
export class SplitGutterDragHandleDirective implements OnDestroy {
private readonly gutterNum = inject(GUTTER_NUM_TOKEN)
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef)
private readonly gutterDir = inject(SplitGutterDirective)
private readonly guttersManager = inject(SplitGuttersManagerService)
private readonly splitComponent = inject(SplitComponent)

readonly suppressDefaultCursor = input(false, { transform: booleanAttribute })

protected readonly cursor = computed(() => {
if (this.suppressDefaultCursor()) {
return undefined
}

const direction = this.splitComponent.direction()

switch (direction) {
case 'horizontal':
return 'col-resize'
case 'vertical':
return 'row-resize'
default:
return assertUnreachable(direction, 'SplitDirection')
}
})

constructor() {
this.gutterDir._addToMap(this.gutterDir._gutterToHandleElementMap, this.gutterNum, this.elementRef)
this.guttersManager.addDragHandle(this.gutterNum, this.elementRef)
}

ngOnDestroy(): void {
this.gutterDir._removedFromMap(this.gutterDir._gutterToHandleElementMap, this.gutterNum, this.elementRef)
this.guttersManager.removeDragHandle(this.gutterNum, this.elementRef)
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { Directive, OnDestroy, ElementRef, inject } from '@angular/core'
import { SplitGutterDirective } from './split-gutter.directive'
import { Directive, OnDestroy, ElementRef, inject, input, booleanAttribute, computed } from '@angular/core'
import { GUTTER_NUM_TOKEN } from './gutter-num-token'
import { SplitGuttersManagerService } from './split-gutters-manager.service'

@Directive({
selector: '[asSplitGutterExcludeFromDrag]',
host: {
'[style.cursor]': `cursor()`,
},
})
export class SplitGutterExcludeFromDragDirective implements OnDestroy {
private readonly gutterNum = inject(GUTTER_NUM_TOKEN)
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef)
private readonly gutterDir = inject(SplitGutterDirective)
private readonly guttersManager = inject(SplitGuttersManagerService)

readonly suppressDefaultCursor = input(false, { transform: booleanAttribute })

cursor = computed(() => (this.suppressDefaultCursor() ? undefined : 'default'))

constructor() {
this.gutterDir._addToMap(this.gutterDir._gutterToExcludeDragElementMap, this.gutterNum, this.elementRef)
this.guttersManager.addExcludeDrag(this.gutterNum, this.elementRef)
}

ngOnDestroy(): void {
this.gutterDir._removedFromMap(this.gutterDir._gutterToExcludeDragElementMap, this.gutterNum, this.elementRef)
this.guttersManager.removeExcludeDrag(this.gutterNum, this.elementRef)
}
}
97 changes: 3 additions & 94 deletions projects/angular-split/src/lib/gutter/split-gutter.directive.ts
Original file line number Diff line number Diff line change
@@ -1,105 +1,14 @@
import { Directive, ElementRef, inject, TemplateRef } from '@angular/core'
import type { SplitAreaComponent } from '../split-area/split-area.component'
import { Directive, inject, TemplateRef } from '@angular/core'
import type { SplitGutterContext } from './split-gutter-context'

export interface SplitGutterTemplateContext {
/**
* The area before the gutter.
* In RTL the right area and in LTR the left area
*/
areaBefore: SplitAreaComponent
/**
* The area after the gutter.
* In RTL the left area and in LTR the right area
*/
areaAfter: SplitAreaComponent
/**
* The absolute number of the gutter based on direction (RTL and LTR).
* First gutter is 1, second is 2, etc...
*/
gutterNum: number
/**
* Whether this is the first gutter.
* In RTL the most right area and in LTR the most left area
*/
first: boolean
/**
* Whether this is the last gutter.
* In RTL the most left area and in LTR the most right area
*/
last: boolean
/**
* Whether the gutter is being dragged now
*/
isDragged: boolean
}
export type SplitGutterTemplateContext = SplitGutterContext

@Directive({
selector: '[asSplitGutter]',
})
export class SplitGutterDirective {
readonly template = inject<TemplateRef<SplitGutterTemplateContext>>(TemplateRef)

/**
* The map holds reference to the drag handle elements inside instances
* of the provided template.
*
* @internal
*/
readonly _gutterToHandleElementMap = new Map<number, ElementRef<HTMLElement>[]>()
/**
* The map holds reference to the excluded drag elements inside instances
* of the provided template.
*
* @internal
*/
readonly _gutterToExcludeDragElementMap = new Map<number, ElementRef<HTMLElement>[]>()

/**
* @internal
*/
_canStartDragging(originElement: HTMLElement, gutterNum: number) {
if (this._gutterToExcludeDragElementMap.has(gutterNum)) {
const isInsideExclude = this._gutterToExcludeDragElementMap
.get(gutterNum)
.some((gutterExcludeElement) => gutterExcludeElement.nativeElement.contains(originElement))

if (isInsideExclude) {
return false
}
}

if (this._gutterToHandleElementMap.has(gutterNum)) {
return this._gutterToHandleElementMap
.get(gutterNum)
.some((gutterHandleElement) => gutterHandleElement.nativeElement.contains(originElement))
}

return true
}

/**
* @internal
*/
_addToMap(map: Map<number, ElementRef<HTMLElement>[]>, gutterNum: number, elementRef: ElementRef<HTMLElement>) {
if (map.has(gutterNum)) {
map.get(gutterNum).push(elementRef)
} else {
map.set(gutterNum, [elementRef])
}
}

/**
* @internal
*/
_removedFromMap(map: Map<number, ElementRef<HTMLElement>[]>, gutterNum: number, elementRef: ElementRef<HTMLElement>) {
const elements = map.get(gutterNum)
elements.splice(elements.indexOf(elementRef), 1)

if (elements.length === 0) {
map.delete(gutterNum)
}
}

static ngTemplateContextGuard(_dir: SplitGutterDirective, ctx: unknown): ctx is SplitGutterTemplateContext {
return true
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ElementRef, Injectable, signal } from '@angular/core'

@Injectable()
export class SplitGuttersManagerService {
/**
* The map holds reference to the drag handle elements inside instances
* of the provided template.
*/
private readonly gutterToHandleElementMap = signal(new Map<number, ElementRef<HTMLElement>[]>(), {
equal: () => false,
})
/**
* The map holds reference to the excluded drag elements inside instances
* of the provided template.
*/
private readonly gutterToExcludeDragElementMap = signal(new Map<number, ElementRef<HTMLElement>[]>(), {
equal: () => false,
})

canStartDragging(originElement: HTMLElement, gutterNum: number) {
if (this.gutterToExcludeDragElementMap().has(gutterNum)) {
const isInsideExclude = this.gutterToExcludeDragElementMap()
.get(gutterNum)
.some((gutterExcludeElement) => gutterExcludeElement.nativeElement.contains(originElement))

if (isInsideExclude) {
return false
}
}

if (this.gutterToHandleElementMap().has(gutterNum)) {
return this.gutterToHandleElementMap()
.get(gutterNum)
.some((gutterHandleElement) => gutterHandleElement.nativeElement.contains(originElement))
}

return true
}

hasDragHandles(gutterNum: number) {
return this.gutterToHandleElementMap().has(gutterNum)
}

addDragHandle(gutterNum: number, elementRef: ElementRef<HTMLElement>) {
const map = this.gutterToHandleElementMap()
this.addToMap(map, gutterNum, elementRef)
this.gutterToHandleElementMap.set(map)
}

removeDragHandle(gutterNum: number, elementRef: ElementRef<HTMLElement>) {
const map = this.gutterToHandleElementMap()
this.removeFromMap(map, gutterNum, elementRef)
this.gutterToHandleElementMap.set(map)
}

addExcludeDrag(gutterNum: number, elementRef: ElementRef<HTMLElement>) {
const map = this.gutterToExcludeDragElementMap()
this.addToMap(map, gutterNum, elementRef)
this.gutterToExcludeDragElementMap.set(map)
}

removeExcludeDrag(gutterNum: number, elementRef: ElementRef<HTMLElement>) {
const map = this.gutterToExcludeDragElementMap()
this.removeFromMap(map, gutterNum, elementRef)
this.gutterToExcludeDragElementMap.set(map)
}

private addToMap(
map: Map<number, ElementRef<HTMLElement>[]>,
gutterNum: number,
elementRef: ElementRef<HTMLElement>,
) {
if (map.has(gutterNum)) {
map.get(gutterNum).push(elementRef)
} else {
map.set(gutterNum, [elementRef])
}
}

private removeFromMap(
map: Map<number, ElementRef<HTMLElement>[]>,
gutterNum: number,
elementRef: ElementRef<HTMLElement>,
) {
const elements = map.get(gutterNum)
elements.splice(elements.indexOf(elementRef), 1)

Comment on lines +86 to +87
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removeFromMap assumes the gutter entry exists and that elementRef is present in the array. If map.get(gutterNum) returns undefined (e.g. due to mismatched gutterNum / double-destroy) this will throw, and if indexOf(elementRef) returns -1 it will incorrectly remove the last element. Consider guarding for missing entries and a missing index (no-op) before splicing, and only deleting the key when the array is truly empty.

Suggested change
elements.splice(elements.indexOf(elementRef), 1)
if (!elements) {
return
}
const elementIndex = elements.indexOf(elementRef)
if (elementIndex === -1) {
return
}
elements.splice(elementIndex, 1)

Copilot uses AI. Check for mistakes.
if (elements.length === 0) {
map.delete(gutterNum)
}
}
}
10 changes: 8 additions & 2 deletions projects/angular-split/src/lib/split/split.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,19 @@
touch-action: none;

:host(.as-horizontal) > & {
cursor: col-resize;
height: 100%;

&:not(.with-handles) {
cursor: col-resize;
}
}

:host(.as-vertical) > & {
cursor: row-resize;
width: 100%;

&:not(.with-handles) {
cursor: row-resize;
}
}

:host(.as-disabled) > & {
Expand Down
Loading
Loading