feat(projectFolders): add new ui component

This commit is contained in:
Johannes Millan 2025-09-13 16:49:25 +02:00
parent d86b924ca5
commit dbb3eb02f7
10 changed files with 802 additions and 0 deletions

View file

@ -0,0 +1,33 @@
import { DestroyRef, Directive, effect, ElementRef, inject, input } from '@angular/core';
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { makeDragData } from './dnd.helpers';
@Directive({
selector: '[dndDraggable]',
standalone: true,
})
export class DndDraggableDirective {
private el = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
// Inputs (signal-based)
id = input.required<string>({ alias: 'dndDraggable' });
dndContext = input.required<symbol>();
private cleanup: (() => void) | null = null;
private _bindEffect = effect(() => {
// Rebind when id/context changes
this.cleanup?.();
const id = this.id();
const ctx = this.dndContext();
this.cleanup = draggable({
element: this.el.nativeElement,
getInitialData: () => makeDragData(ctx, id),
});
});
constructor() {
this.destroyRef.onDestroy(() => this.cleanup?.());
}
}

View file

@ -0,0 +1,84 @@
import {
DestroyRef,
Directive,
effect,
ElementRef,
inject,
input,
output,
} from '@angular/core';
import {
dropTargetForElements,
type ElementDropTargetEventBasePayload,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import type { DropData } from './tree.types';
import { asDragData, makeDropData } from './dnd.helpers';
@Directive({
selector: '[dndDropTarget]',
standalone: true,
})
export class DndDropTargetDirective {
private el = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
// Inputs (modern signal-based)
config = input<{ id: string; where: DropData['where'] } | null>(null, {
alias: 'dndDropTarget',
});
dndContext = input.required<symbol>();
// Outputs (modern signal-based)
activeChange = output<boolean>();
indicator = output<{
active: boolean;
element: HTMLElement;
where: DropData['where'];
}>();
private cleanup: (() => void) | null = null;
// Rebind drop target whenever config/context changes
private _bindEffect = effect(() => {
// ensure cleanup of previous binding
this.cleanup?.();
this.cleanup = null;
const cfg = this.config();
if (!cfg) {
return; // disabled target
}
const where = cfg.where;
this.cleanup = dropTargetForElements({
element: this.el.nativeElement,
canDrop: ({ source }) =>
asDragData(source.data as any)?.uniqueContextId === this.dndContext(),
getData: () => makeDropData({ type: 'drop', id: cfg.id, where }),
onDragStart: (p) => this.onActive(p, where),
onDropTargetChange: (p) => this.onActive(p, where),
onDragLeave: () => {
this.activeChange.emit(false);
this.indicator.emit({ active: false, element: this.el.nativeElement, where });
},
onDrop: () => {
this.activeChange.emit(false);
this.indicator.emit({ active: false, element: this.el.nativeElement, where });
},
});
});
constructor() {
this.destroyRef.onDestroy(() => this.cleanup?.());
}
private onActive(
{ location, self }: ElementDropTargetEventBasePayload,
where: DropData['where'],
) {
const [innerMost] = location.current.dropTargets;
const isActive = innerMost?.element === self.element;
this.activeChange.emit(isActive);
this.indicator.emit({ active: isActive, element: this.el.nativeElement, where });
}
}

View file

@ -0,0 +1,29 @@
import { asDragData, asDropData, makeDragData, makeDropData } from './dnd.helpers';
describe('dnd.helpers', () => {
it('creates and parses DragData safely', () => {
const ctx = Symbol('ctx');
const anyData = makeDragData(ctx, 'X');
const parsed = asDragData(anyData);
expect(parsed).toBeTruthy();
expect(parsed!.id).toBe('X');
expect(parsed!.uniqueContextId).toBe(ctx);
});
it('rejects invalid DragData', () => {
const parsed = asDragData({ foo: 'bar' } as any);
expect(parsed).toBeNull();
});
it('creates and parses DropData safely', () => {
const anyDrop = makeDropData({ type: 'drop', id: 'A', where: 'before' });
const parsed = asDropData(anyDrop);
expect(parsed).toEqual({ type: 'drop', id: 'A', where: 'before' });
});
it('handles root drop', () => {
const anyDrop = makeDropData({ type: 'drop', id: '', where: 'root' });
const parsed = asDropData(anyDrop);
expect(parsed).toEqual({ type: 'drop', id: '', where: 'root' });
});
});

View file

@ -0,0 +1,33 @@
import type { DragData, DropData } from './tree.types';
type AnyData = Record<string | symbol, unknown>;
export function makeDragData(ctx: symbol, id: string): AnyData {
const data: DragData = { type: 'item', id, uniqueContextId: ctx };
return data as unknown as AnyData;
}
export function makeDropData(data: DropData): AnyData {
return data as unknown as AnyData;
}
export function asDragData(data: AnyData | null | undefined): DragData | null {
if (!data) return null;
const d = data as unknown as Partial<DragData>;
return d &&
d.type === 'item' &&
typeof d.id === 'string' &&
typeof d.uniqueContextId === 'symbol'
? (d as DragData)
: null;
}
export function asDropData(data: AnyData | null | undefined): DropData | null {
if (!data) return null;
const d = data as unknown as Partial<DropData>;
if (!d || d.type !== 'drop' || typeof d.where !== 'string') return null;
if (d.where === 'root') return { type: 'drop', id: '', where: 'root' };
if (typeof d.id === 'string')
return { type: 'drop', id: d.id, where: d.where as DropData['where'] };
return null;
}

View file

@ -0,0 +1,112 @@
<div
class="tree"
#root
>
@for (node of nodes(); track node.id) {
<ng-container
*ngTemplateOutlet="nodeTpl; context: { $implicit: node, level: 0 }"
></ng-container>
}
<!-- root drop area: drop as last item in root -->
<div
class="tree__root-drop"
[class.is-over]="rootOver()"
[dndDropTarget]="{ id: '', where: 'root' }"
[dndContext]="contextId"
(activeChange)="rootOver.set($event)"
(indicator)="onIndicator($event)"
></div>
<!-- floating drop indicator -->
<div
class="tree__indicator"
[style.display]="indicatorVisible() ? 'block' : 'none'"
[style.top.px]="indicatorTop()"
[style.left.px]="indicatorLeft()"
[style.width.px]="indicatorWidth()"
>
<span class="tree__indicator-dot"></span>
<span class="tree__indicator-line"></span>
</div>
</div>
<ng-template
#nodeTpl
let-node
let-level="level"
>
<div
class="tree__item"
[class.tree__item--folder]="isFolder(node)"
[style.paddingLeft.px]="level * indent()"
>
<!-- before drop zone -->
<div
class="tree__drop tree__drop--before"
[class.is-over]="isOver(node.id, 'before')"
[dndDropTarget]="{ id: node.id, where: 'before' }"
[dndContext]="contextId"
(activeChange)="setOver(node.id, 'before', $event)"
(indicator)="onIndicator($event)"
></div>
<div
class="tree__row"
[attr.data-id]="node.id"
[class.is-dragging]="draggingId() === node.id"
[class.is-over]="isOver(node.id, 'inside')"
[dndDropTarget]="isFolder(node) ? { id: node.id, where: 'inside' } : null"
[dndContext]="contextId"
(activeChange)="setOver(node.id, 'inside', $event)"
>
<span
class="tree__handle"
[dndDraggable]="node.id"
[dndContext]="contextId"
title="Drag"
>≡</span
>
@if (isFolder(node)) {
<span
class="tree__expander"
(click)="$event.stopPropagation(); node.expanded = !node.expanded"
>
{{ node.expanded ? '▾' : '▸' }}
</span>
}
@if (folderTpl() && isFolder(node)) {
<ng-container
*ngTemplateOutlet="folderTpl(); context: { $implicit: node }"
></ng-container>
} @else {
@if (itemTpl(); as tpl) {
<ng-container
*ngTemplateOutlet="tpl; context: { $implicit: node }"
></ng-container>
} @else {
{{ node.label }}
}
}
</div>
<!-- after drop zone -->
<div
class="tree__drop tree__drop--after"
[class.is-over]="isOver(node.id, 'after')"
[dndDropTarget]="{ id: node.id, where: 'after' }"
[dndContext]="contextId"
(activeChange)="setOver(node.id, 'after', $event)"
(indicator)="onIndicator($event)"
></div>
</div>
@if (node.expanded && node.children?.length) {
<div class="tree__group">
@for (child of node.children; track child.id) {
<ng-container
*ngTemplateOutlet="nodeTpl; context: { $implicit: child, level: level + 1 }"
></ng-container>
}
</div>
}
</ng-template>

View file

@ -0,0 +1,102 @@
:host {
display: block;
}
.tree {
display: block;
position: relative;
}
.tree__item {
position: relative;
}
.tree__row {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 28px;
border-radius: 4px;
padding: 2px 6px;
user-select: none;
-webkit-user-select: none;
}
.tree__handle {
cursor: grab;
padding: 0 4px;
user-select: none;
-webkit-user-select: none;
}
.tree__row.is-over {
outline: 2px dashed #2684ff;
}
.tree__row.is-dragging {
opacity: 0.5;
}
.tree__item--folder > .tree__row {
font-weight: 600;
}
.tree__expander {
width: 16px;
text-align: center;
user-select: none;
}
.tree__drop {
height: 8px;
}
.tree__drop--before,
.tree__drop--after {
border-top: 2px solid transparent;
}
.tree__drop.is-over {
border-top-color: #2684ff;
}
.tree__group {
margin-left: 0;
}
.tree__root-drop {
height: 10px;
border-top: 2px dashed transparent;
}
.tree__root-drop.is-over {
border-top-color: #2684ff;
}
/* indicator */
.tree__indicator {
position: absolute;
height: 0;
pointer-events: none;
}
.tree__indicator-dot {
position: absolute;
top: -5px;
left: -7px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #2684ff;
box-shadow: 0 0 0 2px white;
}
.tree__indicator-line {
display: block;
height: 2px;
background: #2684ff;
width: 100%;
position: absolute;
top: -1px;
left: 0;
}

View file

@ -0,0 +1,187 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
ElementRef,
effect,
inject,
input,
output,
signal,
TemplateRef,
contentChild,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { DropWhere, MoveInstruction, TreeNode } from './tree.types';
import { moveNode } from './tree.utils';
import { DndDraggableDirective } from './dnd-draggable.directive';
import { DndDropTargetDirective } from './dnd-drop-target.directive';
import { asDragData, asDropData } from './dnd.helpers';
@Component({
selector: 'tree-dnd',
standalone: true,
imports: [NgTemplateOutlet, DndDraggableDirective, DndDropTargetDirective],
templateUrl: './tree.component.html',
styleUrl: './tree.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TreeDndComponent implements AfterViewInit {
private host = inject(ElementRef<HTMLElement>);
private destroyRef = inject(DestroyRef);
// Unique context to keep interactions scoped to one tree
private ctx = Symbol('tree-dnd');
// expose to template
contextId = this.ctx;
// Inputs (signal-based)
readonly data = input.required<TreeNode[]>();
readonly indent = input(16);
// Local state mirrored from input for internal reordering
readonly nodes = signal<TreeNode[]>([]);
private _syncEffect = effect(() => {
this.nodes.set(this.data() ?? []);
});
// Content templates (signal-based)
readonly itemTpl = contentChild<TemplateRef<any>>('treeItem');
readonly folderTpl = contentChild<TemplateRef<any>>('treeFolder');
// Output: notify consumers of moves
moved = output<MoveInstruction>();
// drag highlight state (ids mapped to where states)
private overMap = signal<Record<string, Partial<Record<DropWhere, boolean>>>>({});
setOver(id: string, where: DropWhere, on: boolean) {
this.overMap.update((m) => {
const entry = { ...(m[id] ?? {}) };
entry[where] = on;
return { ...m, [id]: entry };
});
}
isOver = (id: string, where: DropWhere) =>
computed(() => !!this.overMap()[id]?.[where]);
rootOver = signal<boolean>(false);
draggingId = signal<string | null>(null);
// trackBy not needed with @for track expressions
// Indicator state
indicatorVisible = signal(false);
indicatorTop = signal(0);
indicatorLeft = signal(0);
indicatorWidth = signal(0);
onIndicator(evt: { active: boolean; element: HTMLElement; where: DropWhere }) {
if (!evt.active) {
this.indicatorVisible.set(false);
return;
}
// Only show for before/after/root
if (evt.where === 'inside') {
this.indicatorVisible.set(false);
return;
}
const container = this.host.nativeElement.querySelector('.tree') as HTMLElement;
const containerRect = container.getBoundingClientRect();
const elRect = evt.element.getBoundingClientRect();
let y = elRect.top - containerRect.top + (evt.where === 'after' ? elRect.height : 0);
let left = 0;
let width = containerRect.width;
// try align to row content
const row = evt.element.parentElement?.querySelector(
'.tree__row',
) as HTMLElement | null;
if (row) {
const rowRect = row.getBoundingClientRect();
left = rowRect.left - containerRect.left;
width = containerRect.width - left;
}
this.indicatorTop.set(Math.round(y));
this.indicatorLeft.set(Math.max(0, Math.round(left)));
this.indicatorWidth.set(Math.max(0, Math.round(width)));
this.indicatorVisible.set(true);
}
isFolder(n: TreeNode): boolean {
return !!n.isFolder || n.children !== undefined;
}
ngAfterViewInit() {
const cleanup: Array<() => void> = [];
// Register monitor only (drop-targets + draggables are directives)
cleanup.push(
monitorForElements({
canMonitor: ({ source }) =>
asDragData(source.data as any)?.uniqueContextId === this.ctx,
onDragStart: ({ source }) => {
const data = asDragData(source.data as any);
if (data) this.draggingId.set(data.id);
},
onDrop: ({ location, source }) => {
this.draggingId.set(null);
const dropTargets = location.current.dropTargets;
if (!dropTargets.length) return;
const s = asDragData(source.data as any);
const target = asDropData(dropTargets[0].data as any);
if (!s || !target) return;
if (target.where === 'root') {
const instr: MoveInstruction = {
itemId: s.id,
targetId: '',
where: 'inside',
};
this.nodes.set(moveNode(this.nodes(), instr));
this.moved.emit(instr);
return;
}
if (target.where === 'inside') {
const targetNode = this.findNode(target.id);
if (!targetNode || !this.isFolder(targetNode)) {
return; // disallow dropping inside items
}
}
const instr: MoveInstruction = {
itemId: s.id,
targetId: target.id,
where: target.where,
} as MoveInstruction;
this.nodes.set(moveNode(this.nodes(), instr));
this.moved.emit(instr);
},
}),
);
this.destroyRef.onDestroy(() => {
cleanup.forEach((fn) => fn());
});
}
private findNode(id: string): TreeNode | null {
const stack = [...this.nodes()];
while (stack.length) {
const n = stack.pop()!;
if (n.id === id) return n;
if (n.children) stack.push(...n.children);
}
return null;
}
toggle(node: TreeNode) {
if (!node.children?.length) return;
node.expanded = !node.expanded;
// trigger change
this.nodes.update((list) => [...list]);
}
}

View file

@ -0,0 +1,30 @@
export type TreeId = string;
export interface TreeNode {
id: TreeId;
label: string;
children?: TreeNode[];
expanded?: boolean;
// mark as a folder even if children is empty
isFolder?: boolean;
}
export type DropWhere = 'before' | 'after' | 'inside' | 'root';
export interface DropData {
type: 'drop';
id: TreeId;
where: DropWhere;
}
export interface DragData {
type: 'item';
id: TreeId;
uniqueContextId: symbol;
}
export interface MoveInstruction {
itemId: TreeId;
targetId: TreeId | '';
where: Exclude<DropWhere, 'root'> | 'inside';
}

View file

@ -0,0 +1,90 @@
import { moveNode, isAncestor, getPath } from './tree.utils';
import type { TreeNode, MoveInstruction } from './tree.types';
function root(...children: TreeNode[]): TreeNode[] {
return children;
}
describe('tree.utils', () => {
it('moves before a sibling', () => {
const data = root(
{ id: 'A', label: 'A' },
{ id: 'B', label: 'B' },
{ id: 'C', label: 'C' },
);
const instr: MoveInstruction = { itemId: 'C', targetId: 'A', where: 'before' };
const result = moveNode(data, instr);
expect(result.map((n) => n.id)).toEqual(['C', 'A', 'B']);
});
it('moves after a sibling', () => {
const data = root(
{ id: 'A', label: 'A' },
{ id: 'B', label: 'B' },
{ id: 'C', label: 'C' },
);
const instr: MoveInstruction = { itemId: 'A', targetId: 'B', where: 'after' };
const result = moveNode(data, instr);
expect(result.map((n) => n.id)).toEqual(['B', 'A', 'C']);
});
it('moves inside a folder (as first child)', () => {
const data = root(
{
id: 'A',
label: 'A',
isFolder: true,
expanded: true,
children: [{ id: 'A1', label: 'A1' }],
},
{ id: 'B', label: 'B' },
);
const instr: MoveInstruction = { itemId: 'B', targetId: 'A', where: 'inside' };
const result = moveNode(data, instr);
const a = result.find((n) => n.id === 'A')!;
expect(a.children?.map((n) => n.id)).toEqual(['B', 'A1']);
});
it('prevents moving into own descendant', () => {
const data = root({
id: 'A',
label: 'A',
expanded: true,
children: [{ id: 'A1', label: 'A1' }],
});
const instr: MoveInstruction = { itemId: 'A', targetId: 'A1', where: 'inside' };
const result = moveNode(data, instr);
// unchanged
expect(result).toEqual(data);
});
it('moves to root when target is empty string and where is inside', () => {
const data = root({
id: 'A',
label: 'A',
expanded: true,
children: [{ id: 'A1', label: 'A1' }],
});
const instr: MoveInstruction = { itemId: 'A1', targetId: '', where: 'inside' };
const result = moveNode(data, instr);
expect(result.map((n) => n.id)).toEqual(['A1', 'A']);
});
it('isAncestor and getPath basics', () => {
const data = root(
{
id: 'A',
label: 'A',
children: [{ id: 'A1', label: 'A1', children: [{ id: 'A1a', label: 'A1a' }] }],
},
{ id: 'B', label: 'B' },
);
expect(isAncestor(data, 'A', 'A1a')).toBeTrue();
expect(isAncestor(data, 'A1', 'A1a')).toBeTrue();
expect(isAncestor(data, 'B', 'A1a')).toBeFalse();
expect(getPath(data, 'A1a')).toEqual(['A', 'A1', 'A1a']);
});
});

View file

@ -0,0 +1,102 @@
import { MoveInstruction, TreeId, TreeNode } from './tree.types';
export function cloneNodes(nodes: TreeNode[]): TreeNode[] {
return nodes.map((n) => ({
...n,
children: n.children ? cloneNodes(n.children) : undefined,
}));
}
export function getPath(nodes: TreeNode[], targetId: TreeId): TreeId[] | null {
const path: TreeId[] = [];
const found = dfs(nodes, targetId, path);
return found ? path : null;
function dfs(list: TreeNode[], id: TreeId, acc: TreeId[]): boolean {
for (const node of list) {
acc.push(node.id);
if (node.id === id) return true;
if (node.children && dfs(node.children, id, acc)) return true;
acc.pop();
}
return false;
}
}
export function isAncestor(
nodes: TreeNode[],
ancestorId: TreeId,
possibleDescendantId: TreeId,
): boolean {
const path = getPath(nodes, possibleDescendantId);
return !!path?.includes(ancestorId);
}
export function findAndRemove(nodes: TreeNode[], id: TreeId): { node: TreeNode | null } {
const stack: { parent: TreeNode | null; list: TreeNode[] }[] = [
{ parent: null, list: nodes },
];
while (stack.length) {
const { list } = stack.pop()!;
const idx = list.findIndex((n) => n.id === id);
if (idx !== -1) {
const [node] = list.splice(idx, 1);
return { node };
}
for (const n of list) {
if (n.children?.length) stack.push({ parent: n, list: n.children });
}
}
return { node: null };
}
export function getChildren(nodes: TreeNode[], id: '' | TreeId): TreeNode[] {
if (id === '') return nodes;
const stack = [...nodes];
while (stack.length) {
const n = stack.pop()!;
if (n.id === id) return (n.children ??= []);
if (n.children) stack.push(...n.children);
}
return nodes;
}
export function moveNode(data: TreeNode[], instr: MoveInstruction): TreeNode[] {
if (instr.targetId && instr.itemId === instr.targetId) return data; // no-op
if (instr.targetId && isAncestor(data, instr.itemId, instr.targetId)) return data; // prevent into own child
const nodes = cloneNodes(data);
const { node } = findAndRemove(nodes, instr.itemId);
if (!node) return data;
if (instr.where === 'inside') {
const children = getChildren(nodes, (instr.targetId as TreeId) ?? '');
node.expanded ??= true;
children.unshift(node);
return nodes;
}
// before/after among siblings of target
const parentChildren = getChildrenOfParent(nodes, instr.targetId);
const index = parentChildren.findIndex((n) => n.id === instr.targetId);
if (index === -1) return data;
const insertIndex = instr.where === 'before' ? index : index + 1;
parentChildren.splice(insertIndex, 0, node);
return nodes;
}
function getChildrenOfParent(nodes: TreeNode[], id: TreeId): TreeNode[] {
// returns the array that contains the node with id
const stack: { parent: TreeNode | null; list: TreeNode[] }[] = [
{ parent: null, list: nodes },
];
while (stack.length) {
const ctx = stack.pop()!;
const idx = ctx.list.findIndex((n) => n.id === id);
if (idx !== -1) return ctx.list;
for (const n of ctx.list) {
if (n.children?.length) stack.push({ parent: n, list: n.children });
}
}
return nodes;
}