Use TRee component for Environment dialog (#288)

This commit is contained in:
Gregory Schier
2025-10-31 09:16:29 -07:00
committed by GitHub
parent c9698c0f23
commit e3e67c8df7
25 changed files with 502 additions and 342 deletions

View File

@@ -51,9 +51,10 @@ export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
getItemKey: (item: T) => string;
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
getContextMenu?: (items: T[]) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
ItemInner: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;
className?: string;
onActivate?: (item: T) => void;
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
@@ -86,7 +87,8 @@ function TreeInner<T extends { id: string }>(
onActivate,
onDragEnd,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
root,
treeId,
}: TreeProps<T>,
@@ -108,6 +110,20 @@ function TreeInner<T extends { id: string }>(
}
}, []);
// Select the first item on first render
useEffect(() => {
const ids = jotaiStore.get(selectedIdsFamily(treeId));
const fallback = selectableItems[0];
if (ids.length === 0 && fallback != null) {
jotaiStore.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
jotaiStore.set(focusIdsFamily(treeId), {
anchorId: fallback.node.item.id,
lastId: fallback.node.item.id,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [treeId]);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
@@ -152,6 +168,7 @@ function TreeInner<T extends { id: string }>(
// Ensure there's always a tabbable item after render
useEffect(() => {
requestAnimationFrame(ensureTabbableItem);
ensureTabbableItem();
});
const setSelected = useCallback(
@@ -199,12 +216,12 @@ function TreeInner<T extends { id: string }>(
} else {
// If right-clicked an item that was NOT in the multiple-selection, just use that one
// Also update the selection with it
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
setSelected([item.id], false);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu([item]);
}
};
}, [getContextMenu, selectableItems, treeId]);
}, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, { shiftKey, metaKey, ctrlKey }) => {
@@ -411,6 +428,24 @@ function TreeInner<T extends { id: string }>(
return;
}
const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (overSelectableItem == null) {
return;
}
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
for (const id of draggingItems) {
const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
if (item == null) {
return;
}
const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;
if (item.localDrag && !isSameParent) {
return;
}
}
// Root is anything past the end of the list, so set it to the end
const hoveringRoot = over.id === root.item.id;
if (hoveringRoot) {
@@ -423,12 +458,7 @@ function TreeInner<T extends { id: string }>(
return;
}
const selectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (selectableItem == null) {
return;
}
const node = selectableItem.node;
const node = overSelectableItem.node;
const side = computeSideForDragMove(node.item.id, e);
const item = node.item;
@@ -436,7 +466,7 @@ function TreeInner<T extends { id: string }>(
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
let hoveredChildIndex = selectableItem.index + (side === 'above' ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === 'above' ? 0 : 1);
// Move into the folder if it's open and we're moving below it
if (hovered?.children != null && side === 'below') {
@@ -567,7 +597,8 @@ function TreeInner<T extends { id: string }>(
onClick: handleClick,
getEditOptions,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
};
const handleContextMenu = useCallback(

View File

@@ -10,11 +10,11 @@ export function TreeDragOverlay<T extends { id: string }>({
selectableItems,
getItemKey,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
}: {
treeId: string;
selectableItems: SelectableTreeNode<T>[];
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlotInner'>) {
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
return (
<DragOverlay dropAnimation={null}>
@@ -23,7 +23,7 @@ export function TreeDragOverlay<T extends { id: string }>({
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
getItemKey={getItemKey}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
ItemLeftSlotInner={ItemLeftSlotInner}
forceDepth={0}
/>
</DragOverlay>

View File

@@ -24,12 +24,12 @@ export interface TreeItemClickEvent {
export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
> & {
node: TreeNode<T>;
className?: string;
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void;
};
@@ -47,7 +47,8 @@ function TreeItem_<T extends { id: string }>({
treeId,
node,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
getContextMenu,
onClick,
getEditOptions,
@@ -135,7 +136,7 @@ function TreeItem_<T extends { id: string }>({
}, [node.item.id, treeId]);
const handleSubmitNameEdit = useCallback(
async function submitNameEdit(el: HTMLInputElement) {
async (el: HTMLInputElement) => {
getEditOptions?.(node.item).onChange(node.item, el.value);
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
// Slight delay for the model to propagate to the local store
@@ -243,7 +244,12 @@ function TreeItem_<T extends { id: string }>({
setShowContextMenu(null);
}, []);
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: node.item.id });
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({ id: node.item.id, disabled: node.draggable === false });
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
const handlePointerDown = useCallback(
@@ -290,7 +296,7 @@ function TreeItem_<T extends { id: string }>({
<div
className={classNames(
'text-text-subtle',
'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md',
'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md',
)}
>
{showContextMenu && (
@@ -301,7 +307,11 @@ function TreeItem_<T extends { id: string }>({
/>
)}
{node.children != null ? (
<button tabIndex={-1} className="h-full pl-[0.5rem]" onClick={toggleCollapsed}>
<button
tabIndex={-1}
className="h-full pl-[0.5rem] outline-none"
onClick={toggleCollapsed}
>
<Icon
icon={node.children.length === 0 ? 'dot' : 'chevron_right'}
className={classNames(
@@ -322,12 +332,12 @@ function TreeItem_<T extends { id: string }>({
onClick={handleClick}
onDoubleClick={handleDoubleClick}
disabled={editing}
className="cursor-default tree-item-inner px-2 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
className="cursor-default tree-item-inner pr-1 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
{...attributes}
{...listeners}
tabIndex={isLastSelected ? 0 : -1}
>
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
{ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}
{getEditOptions != null && editing ? (
(() => {
const { defaultValue, placeholder } = getEditOptions(node.item);
@@ -347,6 +357,11 @@ function TreeItem_<T extends { id: string }>({
<ItemInner treeId={treeId} item={node.item} />
)}
</button>
{ItemRightSlot != null ? (
<ItemRightSlot treeId={treeId} item={node.item} />
) : (
<span aria-hidden />
)}
</div>
</li>
);

View File

@@ -1,4 +1,4 @@
import type { CSSProperties} from 'react';
import type { CSSProperties } from 'react';
import { Fragment } from 'react';
import type { SelectableTreeNode } from './common';
import type { TreeProps } from './Tree';
@@ -8,7 +8,7 @@ import { TreeItem } from './TreeItem';
export type TreeItemListProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
> &
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
nodes: SelectableTreeNode<T>[];
@@ -20,17 +20,13 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
export function TreeItemList<T extends { id: string }>({
className,
getContextMenu,
getEditOptions,
getItemKey,
nodes,
onClick,
ItemInner,
ItemLeftSlot,
style,
treeId,
forceDepth,
addTreeItemRef,
...props
}: TreeItemListProps<T>) {
return (
<ul role="tree" style={style} className={className}>
@@ -38,16 +34,12 @@ export function TreeItemList<T extends { id: string }>({
{nodes.map((child, i) => (
<Fragment key={getItemKey(child.node.item)}>
<TreeItem
addRef={addTreeItemRef}
treeId={treeId}
addRef={addTreeItemRef}
node={child.node}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
onClick={onClick}
getEditOptions={getEditOptions}
getContextMenu={getContextMenu}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
{...props}
/>
<TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />
</Fragment>

View File

@@ -7,6 +7,8 @@ export interface TreeNode<T extends { id: string }> {
hidden?: boolean;
parent: TreeNode<T> | null;
depth: number;
draggable?: boolean;
localDrag?: boolean;
}
export interface SelectableTreeNode<T extends { id: string }> {