Skip to content
Merged
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
167 changes: 78 additions & 89 deletions src/lib/Tree.svelte
Original file line number Diff line number Diff line change
@@ -1,109 +1,98 @@
<script lang="ts" context="module">
export interface TreeNodeData<T> {
id: string;
children: TreeNodeData<T>[];
items: T[];
expanded: boolean;
}
</script>

<script lang="ts">
import { createTreeView } from "@melt-ui/svelte";
import { createEventDispatcher, onMount } from "svelte";

export let treeItems: TreeNodeData<any>[] = [];
export let level = 0;
export let toggleMainLevels = true;
import { createTreeView, type TreeView } from "@melt-ui/svelte";
import { setContext } from "svelte";
import TreeNode, { type AbstractTreeNode } from "./TreeNode.svelte";
import { type FolderSlotProps, type ItemSlotProps } from "./TreeChild.svelte";
import { get } from "svelte/store";

const treeView = createTreeView();
interface $$Slots {
folder: FolderSlotProps;
item: ItemSlotProps;
}

const {
elements: { item, group },
states: { expanded },
} = treeView;
export let root: AbstractTreeNode<any>;
export let expanded: string[];
export let selected: string | undefined;
export let scrollToSelected: boolean = true;

const dispatch = createEventDispatcher();
let rootElement: HTMLUListElement;
let rootHeight: number;

onMount(() => {
dispatch("tree-view", treeView);
});
let ctx: TreeView;
$: if ($root?.id) {
ctx = createTreeView({ onExpandedChange: handleExpandedChange });
setContext("tree", ctx);
}

function toggleExpand(id: string, level: number, value: boolean) {
if (!toggleMainLevels) {
return;
}
$: tree = ctx?.elements.tree;
$: ctx?.states.expanded.set(expanded ?? []);

if (level === 0) {
expanded.set(value ? [id] : []);
function handleExpandedChange({
curr,
next,
}: {
curr: string[];
next: string[];
}) {
if (next.length > curr.length) {
const diff = next.find((e) => !curr.includes(e))!;
const rootNodes = get(root).children.map((e) => get(e).id);
if (rootNodes.includes(diff)) {
const others = next.filter((e) => !rootNodes.includes(e));
return [...others, diff];
} else {
return next;
}
} else {
expanded.update((s) => {
return value ? [...s, id] : s.filter((e) => e !== id);
});
return next;
}
}
</script>

{#each treeItems as child}
<button
type="button"
{...$item({ id: child.id, hasChildren: true })}
use:item
on:click={() => toggleExpand(child.id, level, child.expanded)}
>
<slot name="folder" {level} {child} isExpanded={child.expanded} />
</button>
let scrollTimeout: any;
async function scrollToNodeWhenReady(node: HTMLElement, id: string) {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const target = node.querySelector(`#${CSS.escape(id)}`) as HTMLElement;
if (target) {
target.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
return;
}
}, 100);
}

{#if child.expanded}
<div class="subtree" class:root-subtree={level === 0}>
{#if child.children && child.children.length > 0}
<div {...$group({ id: child.id })} use:group class="subtree-child">
<svelte:self treeItems={child.children} level={level + 1}>
<svelte:fragment slot="folder" let:level let:child let:isExpanded>
<slot name="folder" {level} {child} {isExpanded} />
</svelte:fragment>
$: if (selected && scrollToSelected) {
scrollToNodeWhenReady(rootElement, selected);
}
</script>

<svelte:fragment slot="file" let:item>
<slot name="file" {item} />
</svelte:fragment>
</svelte:self>
</div>
{/if}
{#key tree}
<ul
bind:this={rootElement}
bind:clientHeight={rootHeight}
{...$tree}
class="tree"
>
<TreeNode node={root} level={0} {rootElement} {rootHeight}>
<svelte:fragment slot="folder" let:level let:item let:isExpanded>
<slot name="folder" {level} {item} {isExpanded} />
</svelte:fragment>

{#each child.items as item (item.id)}
<slot name="file" {item} />
{/each}
</div>
{/if}
{/each}
<svelte:fragment slot="item" let:item let:level let:isExpanded>
<slot name="item" {level} {item} {isExpanded} />
</svelte:fragment>
</TreeNode>
</ul>
{/key}

<style>
button {
display: flex;
align-items: center;
.tree {
height: 100%;
width: 100%;
font-family: inherit; /* 1 */
font-feature-settings: inherit; /* 1 */
font-variation-settings: inherit; /* 1 */
font-size: 100%; /* 1 */
font-weight: inherit; /* 1 */
line-height: inherit; /* 1 */
letter-spacing: inherit; /* 1 */
color: inherit; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 3 */
background-color: transparent; /* 2 */
cursor: pointer;
}
div.subtree {
display: flex;
flex-direction: column;
}
div.root-subtree {
max-height: 100%;
overflow-y: scroll;
padding-right: 0.25rem;
}
div.subtree-child {
padding-left: 1rem;
overflow: hidden;
}
</style>
182 changes: 182 additions & 0 deletions src/lib/TreeCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<script lang="ts" context="module">
export interface AbstractCardData {
type: string;
name: string;
syncStatus: "synced" | "local" | "cloud";
}
</script>

<script lang="ts">
import {
type AbstractItemData,
type AbstractTreeNode,
} from "./TreeNode.svelte";
import { createEventDispatcher } from "svelte";

const dispatch = createEventDispatcher();

export let isSelected: boolean;
export let isCompatible: boolean;
export let isExpanded: boolean;
export let item: AbstractTreeNode<WithRequiredConfig>;

type WithRequiredConfig<T = unknown> = T & AbstractCardData;

let data: AbstractCardData;
$: data = ($item.data as AbstractItemData<WithRequiredConfig>).item;

function handleDragStart(e: DragEvent) {
dispatch("drag-start");
}

function handleDragEnd(e: DragEvent) {
dispatch("drag-end");
}

function handleClick(e: MouseEvent) {
dispatch("click");
}
</script>

<button
id={$item.id}
class="{isSelected ? 'border-selected' : 'border-unselected'} button"
draggable="true"
on:click={handleClick}
on:dragstart={handleDragStart}
on:dragend={handleDragEnd}
>
<div class="status-indicator">
<div
class={data.syncStatus === "cloud" || data.syncStatus === "synced"
? "status-cloud"
: "status-inactive"}
></div>
<div
class={data.syncStatus === "local" || data.syncStatus === "synced"
? "status-local"
: "status-inactive"}
></div>
</div>
<div class="button-content">
<span class="button-label" class:label-incompatible={isCompatible}>
{data.name}
</span>
<div
class="type-label
{isCompatible ? 'type-compatible' : 'type-incompatible'}"
>
{data.type}
</div>
{#if $item.children.length > 0}
<div class="trigger-container">
<svg
width="14"
height="11"
class:-rotate-90={!isExpanded}
viewBox="0 0 14 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.99968 11L0.9375 0.5L13.0619 0.500001L6.99968 11Z"
fill="#D9D9D9"
/>
</svg>
</div>
{/if}
</div>
</button>

<style>
.button {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
border-width: 1px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
background-color: #2a3439;
}

.border-selected {
border-color: #10b981;
}

.border-unselected {
border-color: rgba(255, 255, 255, 0.1);
}

.status-indicator {
width: 0.25rem;
height: 100%;
display: grid;
grid-template-rows: repeat(2, 1fr);
}

.status-cloud {
background-color: #6ee7b7;
width: 100%;
height: 100%;
}

.status-local {
background-color: #10b981;
width: 100%;
height: 100%;
}

.status-inactive {
background-color: rgba(16, 185, 129, 0.1);
width: 100%;
height: 100%;
}

.button-content {
padding-left: 0.5rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.25rem;
width: 100%;
align-items: center;
}

.button-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}

.label-incompatible {
opacity: 0.75;
}

.type-label {
color: white;
font-size: inherit;
padding: 0.125rem 0.5rem;
border-width: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.type-compatible {
opacity: 1;
border-color: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 255, 255, 0.1);
}

.type-incompatible {
opacity: 0.75;
border-color: transparent;
text-decoration: line-through;
}

.trigger-container {
margin-right: 0.5rem;
}
</style>
Loading