feat(ui): collapsible folders for the list sidebar

Folders render above the top-level lists, each with its own collapsible
children; tapping a folder header folds it instead of selecting it.
Lists move in by dragging onto a folder header (or onto a row inside it)
and out by dropping between top-level rows or via the context menu.

Creating a folder is a toggle in the New-list modal, not a third sidebar
button: it reduces the modal to name + icon and writes a folder row
instead of the list row the create flow pre-created.
This commit is contained in:
mika kuns
2026-09-10 08:29:56 +02:00
parent 13e860f06b
commit 25c9a619dc
9 changed files with 522 additions and 95 deletions
@@ -69,6 +69,60 @@ public partial class ListsIslandView : UserControl
}
}
private void OnFolderTapped(object? sender, RoutedEventArgs e)
{
if (sender is Border { DataContext: ListFolderNavItemViewModel folder }
&& DataContext is ListsIslandViewModel vm)
vm.ToggleFolderCommand.Execute(folder);
}
// Dropping onto the folder *header* appends the list to that folder — the only route into a
// collapsed or empty folder, where there's no child row to drop next to.
private void OnFolderDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if ((!e.DataTransfer?.Contains(ListRowFormat) ?? true)
|| sender is not Border { DataContext: ListFolderNavItemViewModel folder })
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat);
var source = sourceId is null ? null : vm.UserLists.FirstOrDefault(r => r.Id == sourceId);
if (source is null || source.FolderId == folder.Id)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
vm.SetFolderDropTarget(folder);
e.DragEffects = DragDropEffects.Move;
}
private async void OnFolderDrop(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) return;
try
{
if (sender is not Border { DataContext: ListFolderNavItemViewModel folder }) return;
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat);
var source = sourceId is null ? null : vm.UserLists.FirstOrDefault(r => r.Id == sourceId);
if (source is null) return;
vm.ClearDropHints();
await vm.MoveToFolderAsync(source, folder);
}
catch
{
vm.ClearDropHints();
throw;
}
}
private void OnListDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
@@ -150,11 +204,14 @@ public partial class ListsIslandView : UserControl
return null;
}
// Next row *within the same container* — the row below the last list of a folder belongs to
// another folder (or to the top level), so it is not a "drop above" candidate here.
private static ListNavItemViewModel? FindNextUserList(ListsIslandViewModel vm, ListNavItemViewModel row)
{
var idx = vm.UserLists.IndexOf(row);
var container = vm.ContainerOf(row);
var idx = container.IndexOf(row);
if (idx < 0) return null;
return idx + 1 < vm.UserLists.Count ? vm.UserLists[idx + 1] : null;
return idx + 1 < container.Count ? container[idx + 1] : null;
}
}