Feat: improve sidebar tree usability
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { For, Show, createSignal, type JSX } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { ChevronRight, Plus, X } from "../../../lib/icons";
|
||||
import { useAppShellData } from "../data/app-shell.context";
|
||||
import { createLongPressGesture } from "../createLongPressGesture";
|
||||
@@ -6,14 +7,17 @@ import {
|
||||
createWorkspaceStaticTarget,
|
||||
createWorkspaceSurfaceTarget,
|
||||
createWorkspaceTreeTarget,
|
||||
getWorkspaceItemTypeDefinition,
|
||||
getWorkspaceNodeIcon,
|
||||
workspaceStaticItems,
|
||||
type SidebarItem,
|
||||
type WorkspaceContextMenuAction,
|
||||
type WorkspaceContextMenuTarget,
|
||||
type WorkspaceItemTypeId,
|
||||
type WorkspaceStaticItem,
|
||||
type WorkspaceTreeNode,
|
||||
} from "../data/shell.data";
|
||||
import { useWorkspaceTreeData } from "../shared/useWorkspaceTreeData";
|
||||
import { WorkspaceMobileActionSheet } from "../WorkspaceMobileActionSheet/WorkspaceMobileActionSheet";
|
||||
import styles from "./MobileWorkspaceBrowser.module.scss";
|
||||
|
||||
@@ -22,6 +26,89 @@ type MobileWorkspaceBrowserProps = {
|
||||
onClose: VoidFunction;
|
||||
};
|
||||
|
||||
type MobileWorkspaceDialogState =
|
||||
| {
|
||||
kind: "text";
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
initialValue: string;
|
||||
onConfirm: (value: string) => void;
|
||||
}
|
||||
| {
|
||||
kind: "confirm";
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
tone?: "danger";
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
type MobileMoveTargetState = {
|
||||
kind: "folder" | "item";
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type MobileMoveDestination = {
|
||||
id: string | null;
|
||||
label: string;
|
||||
depth: number;
|
||||
meta?: string;
|
||||
};
|
||||
|
||||
const collectMoveDestinations = (
|
||||
nodes: readonly WorkspaceTreeNode[],
|
||||
movingTarget: MobileMoveTargetState,
|
||||
depth = 0,
|
||||
ancestorBlocked = false,
|
||||
): MobileMoveDestination[] => {
|
||||
const destinations: MobileMoveDestination[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== "folder") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isBlockedFolder = movingTarget.kind === "folder" && node.id === movingTarget.id;
|
||||
if (!ancestorBlocked && !isBlockedFolder) {
|
||||
destinations.push({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
depth,
|
||||
meta: "Folder",
|
||||
});
|
||||
}
|
||||
|
||||
destinations.push(
|
||||
...collectMoveDestinations(node.children ?? [], movingTarget, depth + 1, ancestorBlocked || isBlockedFolder),
|
||||
);
|
||||
}
|
||||
|
||||
return destinations;
|
||||
};
|
||||
|
||||
const findTreeNodeById = (nodes: readonly WorkspaceTreeNode[], nodeId: string): WorkspaceTreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.kind !== "folder") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nestedMatch = findTreeNodeById(node.children ?? [], nodeId);
|
||||
if (nestedMatch) {
|
||||
return nestedMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isDangerDialogState = (state: MobileWorkspaceDialogState): boolean => state.kind === "confirm" && state.tone === "danger";
|
||||
|
||||
const TreeRow = (props: { node: WorkspaceTreeNode; depth?: number }): JSX.Element => {
|
||||
const depth = props.depth ?? 0;
|
||||
const Icon = getWorkspaceNodeIcon(props.node);
|
||||
@@ -76,6 +163,7 @@ const StaticRow = (props: { item: SidebarItem }): JSX.Element => {
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkspaceStaticRow = (props: {
|
||||
item: WorkspaceStaticItem;
|
||||
onOpenActionSheet: (target: WorkspaceContextMenuTarget) => void;
|
||||
@@ -149,21 +237,240 @@ const WorkspaceTreeBranch = (props: {
|
||||
export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.Element => {
|
||||
const appShellData = useAppShellData();
|
||||
const [actionSheetTarget, setActionSheetTarget] = createSignal<WorkspaceContextMenuTarget | null>(null);
|
||||
const sectionNodes = () => appShellData.workspaceTree().filter((node) => (node.children?.length ?? 0) > 0);
|
||||
const looseNodes = () => appShellData.workspaceTree().filter((node) => (node.children?.length ?? 0) === 0);
|
||||
const [dialogState, setDialogState] = createSignal<MobileWorkspaceDialogState | null>(null);
|
||||
const [dialogValue, setDialogValue] = createSignal("");
|
||||
const [moveTarget, setMoveTarget] = createSignal<MobileMoveTargetState | null>(null);
|
||||
const { workspaceTreeNodes, createFolder, renameFolder, deleteFolder, moveFolder, createItem, deleteItem, moveItem } = useWorkspaceTreeData({
|
||||
activeProjectId: () => appShellData.activeProject().id,
|
||||
fallbackWorkspaceTree: () => appShellData.workspaceTree(),
|
||||
});
|
||||
const workspaceTarget = () => createWorkspaceSurfaceTarget(appShellData.activeProject());
|
||||
|
||||
const moveDestinations = () => {
|
||||
const target = moveTarget();
|
||||
if (!target) {
|
||||
return [] as MobileMoveDestination[];
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: null, label: "Items root", depth: 0, meta: "Root" },
|
||||
...collectMoveDestinations(workspaceTreeNodes(), target),
|
||||
];
|
||||
};
|
||||
|
||||
const resolveCreateItemType = (actionId: string): WorkspaceItemTypeId | null => {
|
||||
switch (actionId) {
|
||||
case "create-doc":
|
||||
return "core.doc";
|
||||
case "create-board":
|
||||
return "core.board.kanban";
|
||||
case "create-list-board":
|
||||
return "core.board.list";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const createPersistedItem = (itemType: WorkspaceItemTypeId, parentId: string | null): void => {
|
||||
const definition = getWorkspaceItemTypeDefinition(itemType);
|
||||
void createItem(definition.defaultCreateLabel, itemType, parentId);
|
||||
};
|
||||
|
||||
const openActionSheet = (target: WorkspaceContextMenuTarget): void => {
|
||||
setActionSheetTarget(target);
|
||||
};
|
||||
|
||||
const closeActionSheet = (): void => {
|
||||
setActionSheetTarget(null);
|
||||
};
|
||||
|
||||
const closeMoveSheet = (): void => {
|
||||
setMoveTarget(null);
|
||||
};
|
||||
|
||||
const openWorkspaceActionSheet = (): void => {
|
||||
openActionSheet(workspaceTarget());
|
||||
};
|
||||
|
||||
const handleActionSelect = (_action: WorkspaceContextMenuAction, _target: WorkspaceContextMenuTarget): void => {
|
||||
// Mobile first pass only establishes the action-sheet IA and long-press behavior.
|
||||
const closeDialog = (): void => {
|
||||
setDialogState(null);
|
||||
setDialogValue("");
|
||||
};
|
||||
|
||||
const openMoveSheet = (target: MobileMoveTargetState): void => {
|
||||
setMoveTarget(target);
|
||||
};
|
||||
|
||||
const openTextDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "text" }>, "kind">): void => {
|
||||
setDialogValue(config.initialValue);
|
||||
setDialogState({ kind: "text", ...config });
|
||||
};
|
||||
|
||||
const openConfirmDialog = (config: Omit<Extract<MobileWorkspaceDialogState, { kind: "confirm" }>, "kind">): void => {
|
||||
setDialogValue("");
|
||||
setDialogState({ kind: "confirm", ...config });
|
||||
};
|
||||
|
||||
const submitDialog = (): void => {
|
||||
const state = dialogState();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.kind === "text") {
|
||||
const value = dialogValue().trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
state.onConfirm(value);
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog();
|
||||
state.onConfirm();
|
||||
};
|
||||
|
||||
const handleMoveDestinationSelect = (destinationId: string | null): void => {
|
||||
const target = moveTarget();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const destinationNode = destinationId ? findTreeNodeById(workspaceTreeNodes(), destinationId) : null;
|
||||
const targetIndex = destinationNode?.kind === "folder"
|
||||
? destinationNode.children?.length ?? 0
|
||||
: destinationId
|
||||
? 0
|
||||
: workspaceTreeNodes().length;
|
||||
|
||||
closeMoveSheet();
|
||||
|
||||
if (target.kind === "folder") {
|
||||
void moveFolder(target.id, destinationId, targetIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
void moveItem(target.id, destinationId, targetIndex);
|
||||
};
|
||||
|
||||
const handleActionSelect = (action: WorkspaceContextMenuAction, target: WorkspaceContextMenuTarget): void => {
|
||||
const createItemType = resolveCreateItemType(action.id);
|
||||
if (createItemType) {
|
||||
switch (target.kind) {
|
||||
case "workspace":
|
||||
case "home":
|
||||
createPersistedItem(createItemType, null);
|
||||
return;
|
||||
case "folder":
|
||||
createPersistedItem(createItemType, target.id);
|
||||
return;
|
||||
case "settings":
|
||||
case "item":
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (action.id) {
|
||||
case "new-folder": {
|
||||
if (target.kind === "settings" || target.kind === "item") {
|
||||
return;
|
||||
}
|
||||
|
||||
openTextDialog({
|
||||
title: "New folder",
|
||||
message: target.kind === "folder" ? `Create a folder inside "${target.label}".` : "Create a folder at the root of Items.",
|
||||
confirmLabel: "Create",
|
||||
initialValue: "Untitled folder",
|
||||
onConfirm: (name) => {
|
||||
void createFolder(name, target.kind === "folder" ? target.id : null);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "rename-folder": {
|
||||
if (target.kind !== "folder") {
|
||||
return;
|
||||
}
|
||||
|
||||
openTextDialog({
|
||||
title: "Rename folder",
|
||||
message: `Update the name for "${target.label}".`,
|
||||
confirmLabel: "Save",
|
||||
initialValue: target.label,
|
||||
onConfirm: (name) => {
|
||||
if (name === target.label) {
|
||||
return;
|
||||
}
|
||||
void renameFolder(target.id, name);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "move-folder": {
|
||||
if (target.kind !== "folder") {
|
||||
return;
|
||||
}
|
||||
|
||||
openMoveSheet({
|
||||
kind: "folder",
|
||||
id: target.id,
|
||||
label: target.label,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "delete-folder": {
|
||||
if (target.kind !== "folder") {
|
||||
return;
|
||||
}
|
||||
|
||||
openConfirmDialog({
|
||||
title: "Delete folder?",
|
||||
message: `"${target.label}" and everything inside it will be removed.`,
|
||||
confirmLabel: "Delete",
|
||||
tone: "danger",
|
||||
onConfirm: () => {
|
||||
void deleteFolder(target.id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "move-doc":
|
||||
case "move-board":
|
||||
case "move-list-board": {
|
||||
if (target.kind !== "item") {
|
||||
return;
|
||||
}
|
||||
|
||||
openMoveSheet({
|
||||
kind: "item",
|
||||
id: target.id,
|
||||
label: target.label,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "delete-doc":
|
||||
case "delete-board":
|
||||
case "delete-list-board": {
|
||||
if (target.kind !== "item") {
|
||||
return;
|
||||
}
|
||||
|
||||
openConfirmDialog({
|
||||
title: "Delete item?",
|
||||
message: `"${target.label}" will be removed from the project tree.`,
|
||||
confirmLabel: "Delete",
|
||||
tone: "danger",
|
||||
onConfirm: () => {
|
||||
void deleteItem(target.id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const workspaceLongPress = createLongPressGesture({
|
||||
@@ -212,35 +519,119 @@ export const MobileWorkspaceBrowser = (props: MobileWorkspaceBrowserProps): JSX.
|
||||
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="workspace">
|
||||
<span class={styles.sectionLabel}>Workspace</span>
|
||||
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="workspace">
|
||||
<For each={workspaceStaticItems}>
|
||||
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
|
||||
</For>
|
||||
<For each={workspaceStaticItems}>
|
||||
{(item): JSX.Element => <WorkspaceStaticRow item={item} onOpenActionSheet={openActionSheet} />}
|
||||
</For>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="items">
|
||||
<span class={styles.sectionLabel}>Items</span>
|
||||
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="items">
|
||||
<WorkspaceTreeBranch nodes={sectionNodes()} onOpenActionSheet={openActionSheet} />
|
||||
<WorkspaceTreeBranch nodes={workspaceTreeNodes()} onOpenActionSheet={openActionSheet} />
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Show when={looseNodes().length > 0}>
|
||||
<section class={styles.sectionBlock} data-slot="mobile-workspace-section" data-section-id="more">
|
||||
<span class={styles.sectionLabel}>More</span>
|
||||
<ul class={styles.treeList} data-slot="mobile-workspace-list" data-section-id="more">
|
||||
<WorkspaceTreeBranch nodes={looseNodes()} onOpenActionSheet={openActionSheet} />
|
||||
</ul>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<WorkspaceMobileActionSheet
|
||||
target={actionSheetTarget()}
|
||||
onClose={closeActionSheet}
|
||||
onSelect={handleActionSelect}
|
||||
/>
|
||||
<WorkspaceMobileActionSheet target={actionSheetTarget()} onClose={closeActionSheet} onSelect={handleActionSelect} />
|
||||
|
||||
<Show when={moveTarget()}>
|
||||
{(state): JSX.Element => (
|
||||
<Portal>
|
||||
<div class={styles.moveSheetLayer} data-ui="mobile-workspace-move-sheet">
|
||||
<button class={styles.dialogBackdrop} type="button" aria-label="Close move sheet" onClick={closeMoveSheet} />
|
||||
<section class={styles.moveSheet} aria-label={`Move ${state().label}`}>
|
||||
<div class={styles.moveSheetHandle} aria-hidden="true" />
|
||||
<div class={styles.moveSheetHeader}>
|
||||
<div class={styles.moveSheetHeaderCopy}>
|
||||
<span class={styles.moveSheetEyebrow}>Move {state().kind}</span>
|
||||
<strong class={styles.moveSheetTitle}>{state().label}</strong>
|
||||
<p class={styles.moveSheetMessage}>Choose a new location in the project tree.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.moveSection}>
|
||||
<span class={styles.moveSectionLabel}>Destination</span>
|
||||
<div class={styles.moveDestinationList}>
|
||||
<For each={moveDestinations()}>
|
||||
{(destination): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
class={styles.moveDestinationButton}
|
||||
style={{ "--move-depth": `${destination.depth}` }}
|
||||
onClick={(): void => handleMoveDestinationSelect(destination.id)}
|
||||
>
|
||||
<span class={styles.moveDestinationLabel}>{destination.label}</span>
|
||||
<Show when={destination.meta}>
|
||||
<span class={styles.moveDestinationMeta}>{destination.meta}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={styles.moveSheetFooter}>
|
||||
<button class={styles.dialogSecondaryButton} type="button" onClick={closeMoveSheet}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={dialogState()}>
|
||||
{(state): JSX.Element => (
|
||||
<Portal>
|
||||
<div class={styles.dialogLayer} data-ui="mobile-workspace-dialog">
|
||||
<button class={styles.dialogBackdrop} type="button" aria-label="Close dialog" onClick={closeDialog} />
|
||||
<section class={styles.dialogCard} aria-label={state().title}>
|
||||
<div class={styles.dialogCopy}>
|
||||
<strong class={styles.dialogTitle}>{state().title}</strong>
|
||||
<p class={styles.dialogMessage}>{state().message}</p>
|
||||
</div>
|
||||
|
||||
<Show when={state().kind === "text"}>
|
||||
<input
|
||||
class={styles.dialogInput}
|
||||
type="text"
|
||||
value={dialogValue()}
|
||||
onInput={(event): void => {
|
||||
setDialogValue(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
submitDialog();
|
||||
}
|
||||
}}
|
||||
autofocus
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class={styles.dialogActions}>
|
||||
<button class={styles.dialogSecondaryButton} type="button" onClick={closeDialog}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
classList={{
|
||||
[styles.dialogPrimaryButton]: true,
|
||||
[styles.dialogDangerButton]: isDangerDialogState(state()),
|
||||
}}
|
||||
type="button"
|
||||
onClick={submitDialog}
|
||||
>
|
||||
{state().confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user