Split codebase (#455)

This commit is contained in:
Gregory Schier
2026-05-07 15:50:10 -07:00
committed by GitHub
parent d2dc719cc6
commit 10559c8f4f
742 changed files with 7686 additions and 3249 deletions

8
packages/ui/package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "@yaakapp-internal/ui",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts"
}

View File

@@ -0,0 +1,28 @@
import classNames from "classnames";
import type { ReactNode } from "react";
export interface BannerProps {
children: ReactNode;
className?: string;
color?: "primary" | "secondary" | "success" | "notice" | "warning" | "danger" | "info";
}
export function Banner({ children, className, color }: BannerProps) {
return (
<div className="w-auto grid grid-rows-1 max-h-full flex-0">
<div
className={classNames(
className,
color && "bg-surface",
`x-theme-banner--${color}`,
"border border-border border-dashed",
"px-4 py-2 rounded-lg select-auto cursor-auto",
"overflow-auto text-text",
"mb-auto", // Don't stretch all the way down if the parent is in grid or flexbox
)}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { HTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon";
type ButtonVariant = "border" | "solid";
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
innerClassName?: string;
color?: Color | "custom" | "default";
tone?: Color | "default";
variant?: ButtonVariant;
isLoading?: boolean;
size?: ButtonSize;
justify?: "start" | "center";
type?: "button" | "submit";
forDropdown?: boolean;
disabled?: boolean;
title?: string;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
isLoading,
className,
innerClassName,
children,
color,
tone,
forDropdown,
type = "button",
justify = "center",
size = "md",
variant = "solid",
leftSlot,
rightSlot,
disabled,
title,
onClick,
...props
}: ButtonProps,
ref,
) {
const resolvedColor = color ?? tone ?? "default";
const isDisabled = disabled || isLoading;
return (
<button
ref={ref}
type={type}
className={classNames(
className,
"x-theme-button",
`x-theme-button--${variant}`,
`x-theme-button--${variant}--${resolvedColor}`,
"border",
"max-w-full min-w-0",
"hocus:opacity-100",
"whitespace-nowrap outline-none",
"flex-shrink-0 flex items-center",
"outline-0",
isDisabled ? "pointer-events-none opacity-disabled" : "pointer-events-auto",
justify === "start" && "justify-start",
justify === "center" && "justify-center",
size === "md" && "h-md px-3 rounded-md",
size === "sm" && "h-sm px-2.5 rounded-md",
size === "xs" && "h-xs px-2 text-sm rounded-md",
size === "2xs" && "h-2xs px-2 text-xs rounded",
size === "auto" && "px-3 py-2 rounded-md",
variant === "solid" && "border-transparent",
variant === "solid" &&
resolvedColor === "custom" &&
"focus-visible:outline-2 outline-border-focus",
variant === "solid" &&
resolvedColor !== "custom" &&
"text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle",
variant === "solid" &&
resolvedColor !== "custom" &&
resolvedColor !== "default" &&
"bg-surface",
variant === "border" && "border",
variant === "border" &&
resolvedColor !== "custom" &&
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
)}
disabled={isDisabled}
onClick={onClick}
onDoubleClick={(e) => {
e.stopPropagation();
}}
title={title}
{...props}
>
{isLoading ? (
<LoadingIcon size={size === "auto" ? "md" : size} className="mr-1" />
) : leftSlot ? (
<div className="mr-2">{leftSlot}</div>
) : null}
<div
className={classNames(
"truncate w-full",
justify === "start" ? "text-left" : "text-center",
innerClassName,
)}
>
{children}
</div>
{rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && (
<Icon
icon="chevron_down"
size={size === "auto" ? "md" : size}
className="ml-1 -mr-1 relative top-[0.1em]"
/>
)}
</button>
);
});

View File

@@ -0,0 +1,34 @@
import classNames from "classnames";
import type { CSSProperties } from "react";
import { memo } from "react";
interface Props {
className?: string;
style?: CSSProperties;
orientation?: "horizontal" | "vertical";
}
export const DropMarker = memo(
function DropMarker({ className, style, orientation = "horizontal" }: Props) {
return (
<div
style={style}
className={classNames(
className,
"absolute pointer-events-none z-50",
orientation === "horizontal" && "w-full",
orientation === "vertical" && "w-0 top-0 bottom-0",
)}
>
<div
className={classNames(
"absolute bg-primary rounded-full",
orientation === "horizontal" && "left-2 right-2 -bottom-[0.1rem] h-[0.2rem]",
orientation === "vertical" && "-left-[0.1rem] top-0 bottom-0 w-[0.2rem]",
)}
/>
</div>
);
},
() => true,
);

View File

@@ -0,0 +1,23 @@
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
}
export function FormattedError({ children, className }: Props) {
return (
<pre
className={classNames(
className,
"cursor-text select-auto",
"[&_*]:cursor-text [&_*]:select-auto",
"font-mono text-sm w-full bg-surface-highlight p-3 rounded",
"whitespace-pre-wrap border border-danger border-dashed overflow-x-auto",
)}
>
{children}
</pre>
);
}

View File

@@ -0,0 +1,95 @@
import classNames from "classnames";
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
import { useMemo } from "react";
import { useIsFullscreen } from "../hooks/useIsFullscreen";
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "../lib/constants";
import { WindowControls } from "./WindowControls";
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
size: "md" | "lg";
ignoreControlsSpacing?: boolean;
onlyXWindowControl?: boolean;
hideControls?: boolean;
osType: string;
hideWindowControls: boolean;
useNativeTitlebar: boolean;
interfaceScale: number;
}
export function HeaderSize({
className,
style,
size,
ignoreControlsSpacing,
onlyXWindowControl,
children,
hideControls,
osType,
hideWindowControls,
useNativeTitlebar,
interfaceScale,
}: HeaderSizeProps) {
const isFullscreen = useIsFullscreen();
const finalStyle = useMemo<CSSProperties>(() => {
const s = { ...style };
// Set the height (use min-height because scaling font size may make it larger
if (size === "md") s.minHeight = HEADER_SIZE_MD;
if (size === "lg") s.minHeight = HEADER_SIZE_LG;
if (useNativeTitlebar) {
// No style updates when using native titlebar
} else if (osType === "macos") {
if (!isFullscreen) {
// Add large padding for window controls
s.paddingLeft = 76 / interfaceScale;
}
} else if (!ignoreControlsSpacing && !hideWindowControls) {
s.paddingRight = WINDOW_CONTROLS_WIDTH;
}
return s;
}, [
ignoreControlsSpacing,
isFullscreen,
hideWindowControls,
interfaceScale,
size,
style,
useNativeTitlebar,
osType,
]);
return (
<div
data-tauri-drag-region
style={finalStyle}
className={classNames(
className,
"pt-[1px]", // Make up for bottom border
"select-none relative flex items-center",
"w-full border-b border-border-subtle min-w-0",
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
<div
data-tauri-drag-region
className={classNames(
"pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid",
"px-1", // Give it some space on either end for focus outlines
)}
>
{children}
</div>
{!hideControls && !useNativeTitlebar && (
<WindowControls
onlyX={onlyXWindowControl}
osType={osType}
hideWindowControls={hideWindowControls}
useNativeTitlebar={useNativeTitlebar}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import classNames from "classnames";
import type { HTMLAttributes } from "react";
interface Props extends HTMLAttributes<HTMLHeadingElement> {
level?: 1 | 2 | 3;
}
export function Heading({ className, level = 1, ...props }: Props) {
const Component = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
return (
<Component
className={classNames(
className,
"font-semibold text-text",
level === 1 && "text-2xl",
level === 2 && "text-xl",
level === 3 && "text-lg",
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,332 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import {
AlarmClockIcon,
AlertTriangleIcon,
ArchiveIcon,
ArrowBigDownDashIcon,
ArrowBigLeftDashIcon,
ArrowBigRightDashIcon,
ArrowBigRightIcon,
ArrowBigUpDashIcon,
ArrowDownIcon,
ArrowDownToDotIcon,
ArrowDownToLineIcon,
ArrowLeftIcon,
ArrowRightCircleIcon,
ArrowRightIcon,
ArrowUpDownIcon,
ArrowUpFromDotIcon,
ArrowUpFromLineIcon,
ArrowUpIcon,
BadgeCheckIcon,
BookOpenText,
BoxIcon,
CakeIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsDownUpIcon,
ChevronsUpDownIcon,
CircleAlertIcon,
CircleDashedIcon,
CircleDollarSignIcon,
CircleFadingArrowUpIcon,
CircleHelpIcon,
CircleOffIcon,
CirclePauseIcon,
CirclePlayIcon,
CircleStopIcon,
ClipboardPasteIcon,
ClockIcon,
CodeIcon,
Columns2Icon,
CommandIcon,
CookieIcon,
CopyCheck,
CopyIcon,
CornerRightDownIcon,
CornerRightUpIcon,
CreditCardIcon,
CrosshairIcon,
DotIcon,
DownloadIcon,
EllipsisIcon,
EllipsisVerticalIcon,
ExpandIcon,
ExternalLinkIcon,
EyeIcon,
EyeOffIcon,
FileCodeIcon,
FileIcon,
FileTextIcon,
FilterIcon,
FlameIcon,
FlaskConicalIcon,
FolderCodeIcon,
FolderCogIcon,
FolderDownIcon,
FolderGitIcon,
FolderIcon,
FolderInputIcon,
FolderOpenIcon,
FolderOutputIcon,
FolderSymlinkIcon,
FolderSyncIcon,
FolderUpIcon,
GiftIcon,
GitBranchIcon,
GitBranchPlusIcon,
GitCommitIcon,
GitCommitVerticalIcon,
GitForkIcon,
GitPullRequestIcon,
GlobeIcon,
GripVerticalIcon,
HandIcon,
HardDriveDownloadIcon,
HistoryIcon,
HomeIcon,
ImportIcon,
InfoIcon,
KeyboardIcon,
KeyRoundIcon,
LockIcon,
LockOpenIcon,
MergeIcon,
MessageSquare,
MinusCircleIcon,
MinusIcon,
MoonIcon,
MoreVerticalIcon,
PaletteIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PencilIcon,
PinIcon,
PinOffIcon,
Plug,
PlusCircleIcon,
PlusIcon,
PuzzleIcon,
RefreshCcwIcon,
RefreshCwIcon,
RocketIcon,
RotateCcwIcon,
Rows2Icon,
SaveIcon,
SearchIcon,
SendHorizontalIcon,
SettingsIcon,
ShieldAlertIcon,
ShieldCheckIcon,
ShieldIcon,
ShieldOffIcon,
SparklesIcon,
SquareCheckIcon,
SquareIcon,
SquareTerminalIcon,
SunIcon,
TableIcon,
Trash2Icon,
UploadIcon,
VariableIcon,
Wand2Icon,
WifiIcon,
WrenchIcon,
XIcon,
} from "lucide-react";
import type { CSSProperties, HTMLAttributes } from "react";
import { memo } from "react";
const icons = {
alarm_clock: AlarmClockIcon,
alert_triangle: AlertTriangleIcon,
archive: ArchiveIcon,
arrow_big_down_dash: ArrowBigDownDashIcon,
arrow_big_left_dash: ArrowBigLeftDashIcon,
arrow_big_right: ArrowBigRightIcon,
arrow_big_right_dash: ArrowBigRightDashIcon,
arrow_big_up_dash: ArrowBigUpDashIcon,
arrow_down: ArrowDownIcon,
arrow_down_to_dot: ArrowDownToDotIcon,
arrow_down_to_line: ArrowDownToLineIcon,
arrow_left: ArrowLeftIcon,
arrow_right: ArrowRightIcon,
arrow_right_circle: ArrowRightCircleIcon,
arrow_up: ArrowUpIcon,
arrow_up_down: ArrowUpDownIcon,
arrow_up_from_dot: ArrowUpFromDotIcon,
arrow_up_from_line: ArrowUpFromLineIcon,
badge_check: BadgeCheckIcon,
book_open_text: BookOpenText,
box: BoxIcon,
cake: CakeIcon,
chat: MessageSquare,
check: CheckIcon,
check_circle: CheckCircleIcon,
check_square_checked: SquareCheckIcon,
check_square_unchecked: SquareIcon,
chevron_down: ChevronDownIcon,
chevron_left: ChevronLeftIcon,
chevrons_up_down: ChevronsUpDownIcon,
chevrons_down_up: ChevronsDownUpIcon,
chevron_right: ChevronRightIcon,
circle_alert: CircleAlertIcon,
circle_dashed: CircleDashedIcon,
circle_dollar_sign: CircleDollarSignIcon,
circle_fading_arrow_up: CircleFadingArrowUpIcon,
clock: ClockIcon,
code: CodeIcon,
columns_2: Columns2Icon,
command: CommandIcon,
cookie: CookieIcon,
copy: CopyIcon,
copy_check: CopyCheck,
corner_right_down: CornerRightDownIcon,
corner_right_up: CornerRightUpIcon,
credit_card: CreditCardIcon,
crosshair: CrosshairIcon,
dot: DotIcon,
download: DownloadIcon,
ellipsis: EllipsisIcon,
ellipsis_vertical: EllipsisVerticalIcon,
expand: ExpandIcon,
external_link: ExternalLinkIcon,
eye: EyeIcon,
eye_closed: EyeOffIcon,
file: FileIcon,
file_code: FileCodeIcon,
file_text: FileTextIcon,
filter: FilterIcon,
flame: FlameIcon,
flask: FlaskConicalIcon,
folder: FolderIcon,
folder_code: FolderCodeIcon,
folder_cog: FolderCogIcon,
folder_git: FolderGitIcon,
folder_input: FolderInputIcon,
folder_open: FolderOpenIcon,
folder_output: FolderOutputIcon,
folder_symlink: FolderSymlinkIcon,
folder_sync: FolderSyncIcon,
folder_down: FolderDownIcon,
folder_up: FolderUpIcon,
gift: GiftIcon,
git_branch: GitBranchIcon,
git_branch_plus: GitBranchPlusIcon,
git_commit: GitCommitIcon,
git_commit_vertical: GitCommitVerticalIcon,
git_fork: GitForkIcon,
git_pull_request: GitPullRequestIcon,
globe: GlobeIcon,
grip_vertical: GripVerticalIcon,
circle_off: CircleOffIcon,
circle_pause: CirclePauseIcon,
circle_play: CirclePlayIcon,
circle_stop: CircleStopIcon,
hand: HandIcon,
hard_drive_download: HardDriveDownloadIcon,
help: CircleHelpIcon,
history: HistoryIcon,
house: HomeIcon,
import: ImportIcon,
info: InfoIcon,
key_round: KeyRoundIcon,
keyboard: KeyboardIcon,
left_panel_hidden: PanelLeftOpenIcon,
left_panel_visible: PanelLeftCloseIcon,
lock: LockIcon,
lock_open: LockOpenIcon,
magic_wand: Wand2Icon,
merge: MergeIcon,
minus: MinusIcon,
minus_circle: MinusCircleIcon,
moon: MoonIcon,
more_vertical: MoreVerticalIcon,
palette: PaletteIcon,
paste: ClipboardPasteIcon,
pencil: PencilIcon,
pin: PinIcon,
plug: Plug,
plus: PlusIcon,
plus_circle: PlusCircleIcon,
puzzle: PuzzleIcon,
refresh: RefreshCwIcon,
rocket: RocketIcon,
rotate_ccw: RotateCcwIcon,
rows_2: Rows2Icon,
save: SaveIcon,
search: SearchIcon,
send_horizontal: SendHorizontalIcon,
settings: SettingsIcon,
shield: ShieldIcon,
shield_check: ShieldCheckIcon,
shield_off: ShieldOffIcon,
sparkles: SparklesIcon,
square_terminal: SquareTerminalIcon,
sun: SunIcon,
table: TableIcon,
text: FileTextIcon,
trash: Trash2Icon,
unpin: PinOffIcon,
update: RefreshCcwIcon,
upload: UploadIcon,
variable: VariableIcon,
wifi: WifiIcon,
wrench: WrenchIcon,
x: XIcon,
_unknown: ShieldAlertIcon,
empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,
};
export interface IconProps {
icon: keyof typeof icons;
className?: string;
style?: CSSProperties;
size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl";
spin?: boolean;
title?: string;
color?: Color | "custom" | "default";
}
export const Icon = memo(function Icon({
icon,
color = "default",
spin,
size = "md",
style,
className,
title,
}: IconProps) {
const Component = icons[icon] ?? icons._unknown;
return (
<Component
style={style}
title={title}
className={classNames(
className,
!spin && "transform-gpu",
spin && "animate-spin",
"flex-shrink-0",
size === "xl" && "h-6 w-6",
size === "lg" && "h-5 w-5",
size === "md" && "h-4 w-4",
size === "sm" && "h-3.5 w-3.5",
size === "xs" && "h-3 w-3",
size === "2xs" && "h-2.5 w-2.5",
color === "default" && "inherit",
color === "danger" && "text-danger",
color === "warning" && "text-warning",
color === "notice" && "text-notice",
color === "info" && "text-info",
color === "success" && "text-success",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
)}
/>
);
});

View File

@@ -0,0 +1,95 @@
import classNames from "classnames";
import type { MouseEvent } from "react";
import { forwardRef, useCallback } from "react";
import { useTimedBoolean } from "../hooks/useTimedBoolean";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
import type { IconProps } from "./Icon";
import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon";
export type IconButtonProps = IconProps &
ButtonProps & {
showConfirm?: boolean;
iconClassName?: string;
iconSize?: IconProps["size"];
iconColor?: IconProps["color"];
title: string;
showBadge?: boolean;
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{
showConfirm,
icon,
color = "default",
spin,
onClick,
className,
iconClassName,
tabIndex,
size = "md",
iconSize,
showBadge,
iconColor,
isLoading,
type = "button",
...props
}: IconButtonProps,
ref,
) {
const [confirmed, setConfirmed] = useTimedBoolean();
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (showConfirm) setConfirmed();
onClick?.(e);
},
[onClick, setConfirmed, showConfirm],
);
return (
<Button
ref={ref}
aria-hidden={icon === "empty"}
disabled={icon === "empty"}
tabIndex={(tabIndex ?? icon === "empty") ? -1 : undefined}
onClick={handleClick}
innerClassName="flex items-center justify-center"
size={size}
color={color}
type={type}
className={classNames(
className,
"group/button relative flex-shrink-0",
"!px-0",
size === "md" && "w-md",
size === "sm" && "w-sm",
size === "xs" && "w-xs",
size === "2xs" && "w-5",
)}
{...props}
>
{showBadge && (
<div className="absolute top-0 right-0 w-1/2 h-1/2 flex items-center justify-center">
<div className="w-2.5 h-2.5 bg-pink-500 rounded-full" />
</div>
)}
{isLoading ? (
<LoadingIcon size={iconSize} className={iconClassName} />
) : (
<Icon
size={iconSize}
icon={confirmed ? "check" : icon}
spin={spin}
color={iconColor}
className={classNames(
iconClassName,
"group-hover/button:text-text",
confirmed && "!text-success",
props.disabled && "opacity-70",
)}
/>
)}
</Button>
);
});

View File

@@ -0,0 +1,15 @@
import classNames from "classnames";
import type { HTMLAttributes } from "react";
export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return (
<code
className={classNames(
className,
"font-mono text-shrink bg-surface-highlight border border-border-subtle flex-grow-0",
"px-1.5 py-0.5 rounded text shadow-inner break-words",
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,35 @@
import classNames from "classnames";
interface Props {
size?: "2xs" | "xs" | "sm" | "md" | "lg" | "xl";
className?: string;
}
export function LoadingIcon({ size = "md", className }: Props) {
const classes = classNames(
className,
"text-inherit flex-shrink-0",
size === "xl" && "h-6 w-6",
size === "lg" && "h-5 w-5",
size === "md" && "h-4 w-4",
size === "sm" && "h-3.5 w-3.5",
size === "xs" && "h-3 w-3",
size === "2xs" && "h-2.5 w-2.5",
"animate-spin",
);
return (
<div
className={classNames(
classes,
"border-[currentColor] border-b-transparent rounded-full",
size === "xl" && "border-[0.2rem]",
size === "lg" && "border-[0.16rem]",
size === "md" && "border-[0.13rem]",
size === "sm" && "border-[0.1rem]",
size === "xs" && "border-[0.08rem]",
size === "2xs" && "border-[0.06rem]",
)}
/>
);
}

View File

@@ -0,0 +1,90 @@
import classNames from "classnames";
import { FocusTrap } from "focus-trap-react";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useRef } from "react";
import { Portal } from "./Portal";
interface Props {
children: ReactNode;
portalName: string;
open: boolean;
onClose?: () => void;
zIndex?: keyof typeof zIndexes;
variant?: "default" | "transparent";
noBackdrop?: boolean;
}
const zIndexes: Record<number, string> = {
10: "z-10",
20: "z-20",
30: "z-30",
40: "z-40",
50: "z-50",
};
export function Overlay({
variant = "default",
zIndex = 30,
open,
onClose,
portalName,
noBackdrop,
children,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
if (noBackdrop) {
return (
<Portal name={portalName}>
{open && (
<FocusTrap focusTrapOptions={{ clickOutsideDeactivates: true }}>
{/* NOTE: <div> wrapper is required for some reason, or FocusTrap complains */}
<div>{children}</div>
</FocusTrap>
)}
</Portal>
);
}
return (
<Portal name={portalName}>
{open && (
<FocusTrap
focusTrapOptions={{
// Allow outside click so we can click things like toasts
allowOutsideClick: true,
delayInitialFocus: true,
checkCanFocusTrap: async () => {
// Not sure why delayInitialFocus: true doesn't help, but having this no-op promise
// seems to be required to make things work.
},
}}
>
<m.div
ref={containerRef}
className={classNames("fixed inset-0", zIndexes[zIndex])}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div
aria-hidden
onClick={onClose}
className={classNames(
"absolute inset-0",
variant === "default" && "bg-backdrop backdrop-blur-sm",
)}
/>
{/* Show the draggable region at the top */}
{/* TODO: Figure out tauri drag region and also make clickable still */}
{variant === "default" && (
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
)}
{children}
</m.div>
</FocusTrap>
)}
</Portal>
);
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { usePortal } from "../hooks/usePortal";
interface Props {
children: ReactNode;
name: string;
}
export function Portal({ children, name }: Props) {
const portal = usePortal(name);
return createPortal(children, portal);
}

View File

@@ -0,0 +1,112 @@
import classNames from "classnames";
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useRef, useState } from "react";
const START_DISTANCE = 7;
export interface ResizeHandleEvent {
x: number;
y: number;
xStart: number;
yStart: number;
}
interface Props {
style?: CSSProperties;
className?: string;
onResizeStart?: () => void;
onResizeEnd?: () => void;
onResizeMove?: (e: ResizeHandleEvent) => void;
onReset?: () => void;
side: "left" | "right" | "top";
justify: "center" | "end" | "start";
}
export function ResizeHandle({
style,
justify,
className,
onResizeStart,
onResizeEnd,
onResizeMove,
onReset,
side,
}: Props) {
const vertical = side === "top";
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{
move: (e: MouseEvent) => void;
up: (e: MouseEvent) => void;
calledStart: boolean;
xStart: number;
yStart: number;
} | null>(null);
const handlePointerDown = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
function move(e: MouseEvent) {
if (moveState.current == null) return;
const xDistance = moveState.current.xStart - e.clientX;
const yDistance = moveState.current.yStart - e.clientY;
const distance = Math.abs(vertical ? yDistance : xDistance);
if (moveState.current.calledStart) {
onResizeMove?.({
x: e.clientX,
y: e.clientY,
xStart: moveState.current.xStart,
yStart: moveState.current.yStart,
});
} else if (distance > START_DISTANCE) {
onResizeStart?.();
moveState.current.calledStart = true;
setIsResizing(true);
}
}
function up() {
setIsResizing(false);
moveState.current = null;
document.documentElement.removeEventListener("mousemove", move);
document.documentElement.removeEventListener("mouseup", up);
onResizeEnd?.();
}
moveState.current = { calledStart: false, xStart: e.clientX, yStart: e.clientY, move, up };
document.documentElement.addEventListener("mousemove", move);
document.documentElement.addEventListener("mouseup", up);
},
[onResizeEnd, onResizeMove, onResizeStart, vertical],
);
return (
<div
aria-hidden
style={style}
onDoubleClick={onReset}
onPointerDown={handlePointerDown}
className={classNames(
className,
"group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full",
vertical ? "w-full h-1.5 cursor-row-resize" : "h-full w-1.5 cursor-col-resize",
justify === "center" && "justify-center",
justify === "end" && "justify-end",
justify === "start" && "justify-start",
side === "right" && "right-0",
side === "left" && "left-0",
side === "top" && "top-0",
)}
>
{isResizing && (
<div
className={classNames(
"fixed -left-[100vw] -right-[100vw] -top-[100vh] -bottom-[100vh]",
vertical && "cursor-row-resize",
!vertical && "cursor-col-resize",
)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import classNames from "classnames";
import * as m from "motion/react-m";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useContainerSize } from "../hooks/useContainerSize";
import { Overlay } from "./Overlay";
import type { ResizeHandleEvent } from "./ResizeHandle";
import { ResizeHandle } from "./ResizeHandle";
const FLOATING_BREAKPOINT = 600;
const side = { gridArea: "side", minWidth: 0 };
const drag = { gridArea: "drag" };
const body = { gridArea: "body", minWidth: 0 };
interface Props {
width: number;
onWidthChange: (width: number) => void;
hidden?: boolean;
onHiddenChange?: (hidden: boolean) => void;
floatingHidden?: boolean;
onFloatingHiddenChange?: (hidden: boolean) => void;
onFloatingChange?: (floating: boolean) => void;
floatingWidth?: number;
defaultWidth?: number;
minWidth?: number;
className?: string;
sidebar: ReactNode;
children: ReactNode;
}
export function SidebarLayout({
width,
onWidthChange,
hidden = false,
onHiddenChange,
floatingHidden = true,
onFloatingHiddenChange,
onFloatingChange,
floatingWidth = 320,
defaultWidth = 250,
minWidth = 50,
className,
sidebar,
children,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const containerSize = useContainerSize(containerRef);
const floating = containerSize.width > 0 && containerSize.width <= FLOATING_BREAKPOINT;
useEffect(() => {
onFloatingChange?.(floating);
}, [floating]); // eslint-disable-line react-hooks/exhaustive-deps
const [isResizing, setIsResizing] = useState(false);
const startWidth = useRef<number | null>(null);
const sideWidth = hidden ? 0 : width;
const styles = useMemo<CSSProperties>(
() => ({
gridTemplate: `
' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)
/ ${sideWidth}px 0 1fr`,
}),
[sideWidth],
);
const handleResizeStart = useCallback(() => {
startWidth.current = width;
setIsResizing(true);
}, [width]);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
startWidth.current = null;
}, []);
const handleResizeMove = useCallback(
({ x, xStart }: ResizeHandleEvent) => {
if (startWidth.current == null) return;
const newWidth = startWidth.current + (x - xStart);
if (newWidth < minWidth) {
onHiddenChange?.(true);
onWidthChange(defaultWidth);
} else {
if (hidden) onHiddenChange?.(false);
onWidthChange(newWidth);
}
},
[minWidth, hidden, onHiddenChange, onWidthChange, defaultWidth],
);
const handleReset = useCallback(() => {
onWidthChange(defaultWidth);
}, [onWidthChange, defaultWidth]);
if (floating) {
return (
<div ref={containerRef} className={classNames(className, "w-full h-full min-h-0")}>
<Overlay
open={!floatingHidden}
portalName="sidebar"
onClose={() => onFloatingHiddenChange?.(true)}
zIndex={20}
>
<m.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
style={{ width: floatingWidth }}
className="absolute top-0 left-0 bottom-0"
>
{sidebar}
</m.div>
</Overlay>
{children}
</div>
);
}
return (
<div
ref={containerRef}
style={styles}
className={classNames(className, "grid w-full h-full", !isResizing && "transition-grid")}
>
<div style={side} className="overflow-hidden">
{sidebar}
</div>
<ResizeHandle
style={drag}
className="-translate-x-[1px]"
justify="end"
side="right"
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
onResizeMove={handleResizeMove}
onReset={handleReset}
/>
<div style={body} className="min-w-0">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useLocalStorage } from "react-use";
import { useContainerSize } from "../hooks/useContainerSize";
import { clamp } from "../lib/clamp";
import type { ResizeHandleEvent } from "./ResizeHandle";
import { ResizeHandle } from "./ResizeHandle";
export type SplitLayoutLayout = "responsive" | "horizontal" | "vertical";
export interface SlotProps {
orientation: "horizontal" | "vertical";
style: CSSProperties;
}
interface Props {
storageKey: string;
firstSlot: (props: SlotProps) => ReactNode;
secondSlot: null | ((props: SlotProps) => ReactNode);
style?: CSSProperties;
className?: string;
defaultRatio?: number;
minHeightPx?: number;
minWidthPx?: number;
layout?: SplitLayoutLayout;
resizeHandleClassName?: string;
}
const baseProperties = { minWidth: 0 };
const areaL = { ...baseProperties, gridArea: "left" };
const areaR = { ...baseProperties, gridArea: "right" };
const areaD = { ...baseProperties, gridArea: "drag" };
const STACK_VERTICAL_WIDTH = 500;
export function SplitLayout({
style,
firstSlot,
secondSlot,
className,
storageKey,
layout = "responsive",
resizeHandleClassName,
defaultRatio = 0.5,
minHeightPx = 10,
minWidthPx = 10,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [widthRaw, setWidth] = useLocalStorage<number>(`${storageKey}_width`);
const [heightRaw, setHeight] = useLocalStorage<number>(`${storageKey}_height`);
const width = widthRaw ?? defaultRatio;
let height = heightRaw ?? defaultRatio;
if (!secondSlot) {
height = 0;
minHeightPx = 0;
}
const size = useContainerSize(containerRef);
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize);
const styles = useMemo<CSSProperties>(() => {
return {
...style,
gridTemplate: vertical
? `
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
' ${areaD.gridArea}' 0
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)
/ 1fr
`
: `
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr
`,
};
}, [style, vertical, height, minHeightPx, width]);
const handleReset = useCallback(() => {
if (vertical) setHeight(defaultRatio);
else setWidth(defaultRatio);
}, [vertical, setHeight, defaultRatio, setWidth]);
const handleResizeMove = useCallback(
(e: ResizeHandleEvent) => {
if (containerRef.current === null) return;
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle(
containerRef.current,
);
const $c = containerRef.current;
const containerWidth =
$c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);
const containerHeight =
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
const mouseStartX = e.xStart;
const mouseStartY = e.yStart;
const startWidth = containerWidth * width;
const startHeight = containerHeight * height;
if (vertical) {
const maxHeightPx = containerHeight - minHeightPx;
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);
setHeight(newHeightPx / containerHeight);
} else {
const maxWidthPx = containerWidth - minWidthPx;
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);
setWidth(newWidthPx / containerWidth);
}
},
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
);
return (
<div
ref={containerRef}
style={styles}
className={classNames(className, "grid w-full h-full overflow-hidden")}
>
{firstSlot({ style: areaL, orientation: vertical ? "vertical" : "horizontal" })}
{secondSlot && (
<>
<ResizeHandle
style={areaD}
className={classNames(
resizeHandleClassName,
vertical ? "-translate-y-1" : "-translate-x-1",
)}
onResizeMove={handleResizeMove}
onReset={handleReset}
side={vertical ? "top" : "left"}
justify="center"
/>
{secondSlot({ style: areaR, orientation: vertical ? "vertical" : "horizontal" })}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import classNames from "classnames";
import type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
const gapClasses = {
0: "gap-0",
0.5: "gap-0.5",
1: "gap-1",
1.5: "gap-1.5",
2: "gap-2",
3: "gap-3",
4: "gap-4",
5: "gap-5",
6: "gap-6",
};
interface HStackProps extends BaseStackProps {
children?: ReactNode;
}
export const HStack = forwardRef(function HStack(
{ className, space, children, alignItems = "center", ...props }: HStackProps,
// oxlint-disable-next-line no-explicit-any
ref: ForwardedRef<any>,
) {
return (
<BaseStack
ref={ref}
className={classNames(className, "flex-row", space != null && gapClasses[space])}
alignItems={alignItems}
{...props}
>
{children}
</BaseStack>
);
});
export type VStackProps = BaseStackProps & {
children: ReactNode;
};
export const VStack = forwardRef(function VStack(
{ className, space, children, ...props }: VStackProps,
// oxlint-disable-next-line no-explicit-any
ref: ForwardedRef<any>,
) {
return (
<BaseStack
ref={ref}
className={classNames(className, "flex-col", space != null && gapClasses[space])}
{...props}
>
{children}
</BaseStack>
);
});
type BaseStackProps = HTMLAttributes<HTMLElement> & {
as?: ComponentType | "ul" | "label" | "form" | "p";
space?: keyof typeof gapClasses;
alignItems?: "start" | "center" | "stretch" | "end";
justifyContent?: "start" | "center" | "end" | "between";
wrap?: boolean;
};
const BaseStack = forwardRef(function BaseStack(
{ className, alignItems, justifyContent, wrap, children, as, ...props }: BaseStackProps,
// oxlint-disable-next-line no-explicit-any
ref: ForwardedRef<any>,
) {
const Component = as ?? "div";
return (
<Component
ref={ref}
className={classNames(
className,
"flex",
wrap && "flex-wrap",
alignItems === "center" && "items-center",
alignItems === "start" && "items-start",
alignItems === "stretch" && "items-stretch",
alignItems === "end" && "items-end",
justifyContent === "start" && "justify-start",
justifyContent === "center" && "justify-center",
justifyContent === "end" && "justify-end",
justifyContent === "between" && "justify-between",
)}
{...props}
>
{children}
</Component>
);
});

View File

@@ -0,0 +1,107 @@
import classNames from "classnames";
import type { ReactNode } from "react";
export function Table({
children,
className,
scrollable,
style,
}: {
children: ReactNode;
className?: string;
scrollable?: boolean;
style?: React.CSSProperties;
}) {
return (
<div style={style} className={classNames("w-full", scrollable && "h-full overflow-y-auto")}>
<table
className={classNames(
className,
"w-full text-sm mb-auto min-w-full max-w-full",
"border-separate border-spacing-0",
scrollable && "[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10",
)}
>
{children}
</table>
</div>
);
}
export function TableBody({ children }: { children: ReactNode }) {
return (
<tbody className="[&>tr:not(:last-child)>td]:border-b [&>tr:not(:last-child)>td]:border-b-surface-highlight">
{children}
</tbody>
);
}
export function TableHead({ children, className }: { children: ReactNode; className?: string }) {
return (
<thead
className={classNames(
className,
"bg-surface [&_th]:border-b [&_th]:border-b-surface-highlight",
)}
>
{children}
</thead>
);
}
export function TableRow({ children }: { children: ReactNode }) {
return <tr>{children}</tr>;
}
export function TableCell({
children,
className,
align = "left",
}: {
children: ReactNode;
className?: string;
align?: "left" | "center" | "right";
}) {
return (
<td
className={classNames(
className,
"py-2 [&:not(:first-child)]:pl-4 whitespace-nowrap",
align === "left" ? "text-left" : align === "center" ? "text-center" : "text-right",
)}
>
{children}
</td>
);
}
export function TruncatedWideTableCell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<TableCell className={classNames(className, "truncate max-w-0 w-full")}>{children}</TableCell>
);
}
export function TableHeaderCell({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
return (
<th
className={classNames(
className,
"py-2 [&:not(:first-child)]:pl-4 text-left text-text-subtle",
)}
>
{children}
</th>
);
}

View File

@@ -0,0 +1,99 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import classNames from "classnames";
import { useState } from "react";
import { WINDOW_CONTROLS_WIDTH } from "../lib/constants";
import { Button } from "./Button";
interface Props {
className?: string;
onlyX?: boolean;
osType: string;
hideWindowControls: boolean;
useNativeTitlebar: boolean;
}
export function WindowControls({
className,
onlyX,
osType,
hideWindowControls,
useNativeTitlebar,
}: Props) {
const [maximized, setMaximized] = useState<boolean>(false);
// Never show controls on macOS or if hideWindowControls is true
if (osType === "macos" || hideWindowControls || useNativeTitlebar) {
return null;
}
return (
<div
className={classNames(
className,
"ml-4 absolute right-0 top-0 bottom-0 flex items-center justify-end",
)}
style={{ width: WINDOW_CONTROLS_WIDTH }}
data-tauri-drag-region
>
{!onlyX && (
<>
<Button
className="!h-full px-4 text-text-subtle hocus:text hocus:bg-surface-highlight rounded-none"
color="custom"
onClick={() => getCurrentWebviewWindow().minimize()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>Minimize</title>
<path fill="currentColor" d="M14 8v1H3V8z" />
</svg>
</Button>
<Button
className="!h-full px-4 text-text-subtle hocus:text hocus:bg-surface-highlight rounded-none"
color="custom"
onClick={async () => {
const w = getCurrentWebviewWindow();
const isMaximized = await w.isMaximized();
if (isMaximized) {
await w.unmaximize();
setMaximized(false);
} else {
await w.maximize();
setMaximized(true);
}
}}
>
{maximized ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>Unmaximize</title>
<g fill="currentColor">
<path d="M3 5v9h9V5zm8 8H4V6h7z" />
<path fillRule="evenodd" d="M5 5h1V4h7v7h-1v1h2V3H5z" clipRule="evenodd" />
</g>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>Maximize</title>
<path fill="currentColor" d="M3 3v10h10V3zm9 9H4V4h8z" />
</svg>
)}
</Button>
</>
)}
<Button
color="custom"
className="!h-full px-4 text-text-subtle rounded-none hocus:bg-danger hocus:text-text"
onClick={() => getCurrentWebviewWindow().close()}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<title>Close</title>
<path
fill="currentColor"
fillRule="evenodd"
d="m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z"
clipRule="evenodd"
/>
</svg>
</Button>
</div>
);
}

View File

@@ -0,0 +1,750 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import {
DndContext,
MeasuringStrategy,
PointerSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { type } from "@tauri-apps/plugin-os";
import classNames from "classnames";
import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from "react";
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useKey, useKeyPressEvent } from "react-use";
import { computeSideForDragMove } from "../../lib/dnd";
import { useStore } from "jotai";
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
import { type CollapsedAtom, CollapsedAtomContext } from "./context";
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common";
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common";
import { TreeDragOverlay } from "./TreeDragOverlay";
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem";
import type { TreeItemListProps } from "./TreeItemList";
import { TreeItemList } from "./TreeItemList";
import { useSelectableItems } from "./useSelectableItems";
/** So we re-calculate after expanding a folder during drag */
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
collapsedAtom: CollapsedAtom;
getItemKey: (item: T) => string;
getContextMenu?: (items: T[]) => unknown[] | Promise<unknown[]>;
renderContextMenu?: ContextMenuRenderer;
ItemInner: 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;
getEditOptions?: (item: T) => {
defaultValue: string;
placeholder?: string;
onChange: (item: T, text: string) => void;
};
}
export interface TreeHandle {
treeId: string;
focus: () => boolean;
hasFocus: () => boolean;
getSelectedItems: () => { id: string }[];
selectItem: (id: string, focus?: boolean) => void;
renameItem: (id: string) => void;
showContextMenu: () => void;
}
function TreeInner<T extends { id: string }>(
{
className,
collapsedAtom,
getContextMenu,
getEditOptions,
getItemKey,
onActivate,
onDragEnd,
renderContextMenu,
ItemInner,
ItemLeftSlotInner,
ItemRightSlot,
root,
treeId,
}: TreeProps<T>,
ref: Ref<TreeHandle>,
) {
const store = useStore();
const treeRef = useRef<HTMLDivElement>(null);
const selectableItems = useSelectableItems(root);
const [showContextMenu, setShowContextMenu] = useState<{
items: unknown[];
x: number;
y: number;
} | null>(null);
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
if (r == null) {
delete treeItemRefs.current[item.id];
} else {
treeItemRefs.current[item.id] = r;
}
}, []);
// Select the first item on first render
// biome-ignore lint/correctness/useExhaustiveDependencies: Only used for initial render
useEffect(() => {
const ids = store.get(selectedIdsFamily(treeId));
const fallback = selectableItems[0];
if (ids.length === 0 && fallback != null) {
store.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
store.set(focusIdsFamily(treeId), {
anchorId: fallback.node.item.id,
lastId: fallback.node.item.id,
});
}
}, [treeId]);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
const isTreeFocused = useCallback(() => {
return treeRef.current?.contains(document.activeElement);
}, []);
const tryFocus = useCallback(() => {
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
'.tree-item button[tabindex="0"]',
);
if ($el == null) {
return false;
}
$el.focus();
$el.scrollIntoView({ block: "nearest" });
return true;
}, []);
const ensureTabbableItem = useCallback(() => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find(
(i) => i.node.item.id === lastSelectedId && !i.node.hidden,
);
// If no item found, default to selecting the first item (prefer leaf node);
if (lastSelectedItem == null) {
const firstLeafItem = selectableItems.find((i) => !i.node.hidden && i.node.children == null);
const firstItem = firstLeafItem ?? selectableItems.find((i) => !i.node.hidden);
if (firstItem != null) {
const id = firstItem.node.item.id;
store.set(selectedIdsFamily(treeId), [id]);
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
}
return;
}
const closest = closestVisibleNode(store, collapsedAtom, lastSelectedItem.node);
if (closest != null) {
const id = closest.item.id;
store.set(selectedIdsFamily(treeId), [id]);
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
}
}, [selectableItems, treeId]);
// Ensure there's always a tabbable item after collapsed state changes
useEffect(() => {
const unsub = store.sub(collapsedAtom, ensureTabbableItem);
return unsub;
}, [ensureTabbableItem, treeId]);
// Ensure there's always a tabbable item after render
useEffect(() => {
requestAnimationFrame(ensureTabbableItem);
});
const hasFocus = useCallback(() => {
return treeRef.current?.contains(document.activeElement) ?? false;
}, []);
const setSelected = useCallback(
(ids: string[], focus: boolean) => {
store.set(selectedIdsFamily(treeId), ids);
// TODO: Figure out a better way than timeout
if (!focus) return;
setTimeout(tryFocus, 50);
},
[treeId, tryFocus],
);
const treeHandle = useMemo<TreeHandle>(
() => ({
treeId,
focus: tryFocus,
hasFocus: hasFocus,
getSelectedItems: () => getSelectedItems(store, treeId, selectableItems),
renameItem: (id) => treeItemRefs.current[id]?.rename(),
selectItem: (id, focus) => {
if (store.get(selectedIdsFamily(treeId)).includes(id)) {
// Already selected
return;
}
store.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
setSelected([id], focus === true);
},
showContextMenu: async () => {
if (getContextMenu == null) return;
const items = getSelectedItems(store, treeId, selectableItems);
const menuItems = await getContextMenu(items);
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
if (rect == null) return;
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
},
}),
[getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus],
);
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
const handleGetContextMenu = useMemo(() => {
if (getContextMenu == null) return;
return (item: T) => {
const items = getSelectedItems(store, treeId, selectableItems);
const isSelected = items.find((i) => i.id === item.id);
if (isSelected) {
// If right-clicked an item that was in the multiple-selection, use the entire selection
return getContextMenu(items);
}
// If right-clicked an item that was NOT in the multiple-selection, just use that one
// Also update the selection with it
setSelected([item.id], false);
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu([item]);
};
}, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
(item, { shiftKey, metaKey, ctrlKey }) => {
const anchorSelectedId = store.get(focusIdsFamily(treeId)).anchorId;
const selectedIdsAtom = selectedIdsFamily(treeId);
const selectedIds = store.get(selectedIdsAtom);
// Mark the item as the last one selected
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
if (shiftKey) {
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
const anchorIndex = validSelectableItems.findIndex(
(i) => i.node.item.id === anchorSelectedId,
);
const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id);
// Nothing was selected yet, so just select this item
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
setSelected([item.id], true);
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
return;
}
if (currIndex > anchorIndex) {
// Selecting down
const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
);
} else if (currIndex < anchorIndex) {
// Selecting up
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
);
} else {
setSelected([item.id], true);
}
} else if (type() === "macos" ? metaKey : ctrlKey) {
const withoutCurr = selectedIds.filter((id) => id !== item.id);
if (withoutCurr.length === selectedIds.length) {
// It wasn't in there, so add it
setSelected([...selectedIds, item.id], true);
} else {
// It was in there, so remove it
setSelected(withoutCurr, true);
}
} else {
// Select single
setSelected([item.id], true);
store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
}
},
[selectableItems, setSelected, treeId],
);
const handleClick = useCallback<NonNullable<TreeItemProps<T>["onClick"]>>(
(item, e) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
handleSelect(item, e);
} else {
handleSelect(item, e);
onActivate?.(item);
}
},
[handleSelect, onActivate],
);
const selectPrevItem = useCallback(
(e: TreeItemClickEvent) => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = validSelectableItems[index - 1];
if (item != null) {
handleSelect(item.node.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
const selectNextItem = useCallback(
(e: TreeItemClickEvent) => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems);
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = validSelectableItems[index + 1];
if (item != null) {
handleSelect(item.node.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
const selectParentItem = useCallback(
(e: TreeItemClickEvent) => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem =
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
if (lastSelectedItem?.parent != null) {
handleSelect(lastSelectedItem.parent.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
useKey(
(e) => e.key === "ArrowUp" || e.key.toLowerCase() === "k",
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
selectPrevItem(e);
},
undefined,
[selectableItems, handleSelect],
);
useKey(
(e) => e.key === "ArrowDown" || e.key.toLowerCase() === "j",
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
selectNextItem(e);
},
undefined,
[selectableItems, handleSelect],
);
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
useKey(
(e) => e.key === "ArrowRight" || e.key === "l",
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
const collapsed = store.get(collapsedAtom);
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if (
lastSelectedId &&
lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] === true
) {
store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: false });
} else {
selectNextItem(e);
}
},
undefined,
[selectableItems, handleSelect],
);
// If the selected item is in a folder, select its parent.
// If the selected item is an expanded folder, collapse it.
useKey(
(e) => e.key === "ArrowLeft" || e.key === "h",
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
const collapsed = store.get(collapsedAtom);
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if (
lastSelectedId &&
lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] !== true
) {
store.set(collapsedAtom, { ...collapsed, [lastSelectedId]: true });
} else {
selectParentItem(e);
}
},
{ options: {} },
[selectableItems, handleSelect],
);
useKeyPressEvent("Escape", async () => {
if (!treeRef.current?.contains(document.activeElement)) return;
clearDragState();
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
if (lastSelectedId == null) return;
setSelected([lastSelectedId], false);
});
const handleDragMove = useCallback(
function handleDragMove(e: DragMoveEvent) {
const over = e.over;
if (!over) {
// Clear the drop indicator when hovering outside the tree
store.set(hoveredParentFamily(treeId), {
parentId: null,
parentDepth: null,
childIndex: null,
index: null,
});
return;
}
// Not sure when or if this happens
if (e.active.rect.current.initial == null) {
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) {
store.set(hoveredParentFamily(treeId), {
parentId: root.item.id,
parentDepth: root.depth,
index: selectableItems.length,
childIndex: selectableItems.length,
});
return;
}
const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (overSelectableItem == null) {
return;
}
const draggingItems = store.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;
}
}
const node = overSelectableItem.node;
const side = computeSideForDragMove(node.item.id, e);
const item = node.item;
let hoveredParent = node.parent;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === "before" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1);
// Move into the folder if it's open and we're moving after it
if (hovered?.children != null && side === "after") {
hoveredParent = hovered;
hoveredChildIndex = 0;
}
const parentId = hoveredParent?.item.id ?? null;
const parentDepth = hoveredParent?.depth ?? null;
const index = hoveredIndex;
const childIndex = hoveredChildIndex;
const existing = store.get(hoveredParentFamily(treeId));
if (
!(
parentId === existing.parentId &&
parentDepth === existing.parentDepth &&
index === existing.index &&
childIndex === existing.childIndex
)
) {
store.set(hoveredParentFamily(treeId), {
parentId,
parentDepth,
index,
childIndex,
});
}
},
[root.depth, root.item.id, selectableItems, treeId],
);
const handleDragStart = useCallback(
function handleDragStart(e: DragStartEvent) {
const selectedItems = getSelectedItems(store, treeId, selectableItems);
const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id);
// If we started dragging an already-selected item, we'll use that
if (isDraggingSelectedItem) {
store.set(
draggingIdsFamily(treeId),
selectedItems.map((i) => i.id),
);
} else {
// If we started dragging a non-selected item, only drag that item
const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item;
if (activeItem != null) {
store.set(draggingIdsFamily(treeId), [activeItem.id]);
// Also update selection to just be this one
handleSelect(activeItem, {
shiftKey: false,
metaKey: false,
ctrlKey: false,
});
}
}
},
[handleSelect, selectableItems, treeId],
);
const clearDragState = useCallback(() => {
store.set(hoveredParentFamily(treeId), {
parentId: null,
parentDepth: null,
index: null,
childIndex: null,
});
store.set(draggingIdsFamily(treeId), []);
}, [treeId]);
const handleDragEnd = useCallback(
function handleDragEnd(e: DragEndEvent) {
// Get this from the store so our callback doesn't change all the time
const {
index: hoveredIndex,
parentId: hoveredParentId,
childIndex: hoveredChildIndex,
} = store.get(hoveredParentFamily(treeId));
const draggingItems = store.get(draggingIdsFamily(treeId));
clearDragState();
// Dropped outside the tree?
if (e.over == null) {
return;
}
const hoveredParentS =
hoveredParentId === root.item.id
? { node: root, depth: 0, index: 0 }
: (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);
const hoveredParent = hoveredParentS?.node ?? null;
if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {
return;
}
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
const draggedNodes: TreeNode<T>[] = draggingItems
.map((id) => {
return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
})
.filter((n) => n != null)
// Filter out invalid drags (dragging into descendant)
.filter(
(n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id),
);
// Work on a local copy of target children
const nextChildren = [...(hoveredParent.children ?? [])];
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
let insertAt = hoveredChildIndex ?? 0;
for (const node of draggedNodes) {
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
if (i !== -1) {
nextChildren.splice(i, 1);
if (i < insertAt) insertAt -= 1; // account for removed-before
}
}
// Batch callback
onDragEnd?.({
items: draggedNodes.map((n) => n.item),
parent: hoveredParent.item,
children: nextChildren.map((c) => c.item),
insertAt,
});
},
[treeId, clearDragState, selectableItems, root, onDragEnd],
);
const treeItemListProps: Omit<
TreeItemListProps<T>,
"nodes" | "treeId" | "activeIdAtom" | "hoveredParent" | "hoveredIndex"
> = {
getItemKey,
getContextMenu: handleGetContextMenu,
renderContextMenu,
onClick: handleClick,
getEditOptions,
ItemInner,
ItemLeftSlotInner,
ItemRightSlot,
};
const handleContextMenu = useCallback(
async (e: MouseEvent<HTMLElement>) => {
if (getContextMenu == null) return;
e.preventDefault();
e.stopPropagation();
const items = await getContextMenu([]);
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
},
[getContextMenu],
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
return (
<CollapsedAtomContext.Provider value={collapsedAtom}>
{showContextMenu &&
renderContextMenu?.({
items: showContextMenu.items,
position: showContextMenu,
onClose: handleCloseContextMenu,
})}
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={clearDragState}
onDragAbort={clearDragState}
onDragMove={handleDragMove}
measuring={measuring}
autoScroll
>
<div
ref={treeRef}
className={classNames(
className,
"outline-none h-full",
"overflow-y-auto overflow-x-hidden",
"grid grid-rows-[auto_1fr]",
)}
>
<div
className={classNames(
"[&_.tree-item.selected_.tree-item-inner]:text-text",
"[&:focus-within]:[&_.tree-item.selected]:bg-surface-active",
"[&:not(:focus-within)]:[&_.tree-item.selected:not([data-context-menu-open])]:bg-surface-highlight",
"[&_.tree-item.selected[data-context-menu-open]]:bg-surface-active",
// Round the items, but only if the ends of the selection.
// Also account for the drop marker being in between items
"[&_.tree-item]:rounded-md",
"[&_.tree-item.selected+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none",
"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
)}
>
<TreeItemList
addTreeItemRef={handleAddTreeItemRef}
nodes={selectableItems}
treeId={treeId}
{...treeItemListProps}
/>
</div>
{/* Assign root ID so we can reuse our same move/end logic */}
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
</div>
<TreeDragOverlay
treeId={treeId}
selectableItems={selectableItems}
ItemInner={ItemInner}
getItemKey={getItemKey}
/>
</DndContext>
</CollapsedAtomContext.Provider>
);
}
// 1) Preserve generics through forwardRef:
const Tree_ = forwardRef(TreeInner) as <T extends { id: string }>(
props: TreeProps<T> & RefAttributes<TreeHandle>,
) => ReactElement | null;
export const Tree = memo(
Tree_,
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
for (const key of Object.keys(prevProps)) {
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
return false;
}
}
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
},
) as typeof Tree_;
function DropRegionAfterList({
id,
onContextMenu,
}: {
id: string;
onContextMenu?: (e: MouseEvent<HTMLDivElement>) => void;
}) {
const { setNodeRef } = useDroppable({ id });
// biome-ignore lint/a11y/noStaticElementInteractions: Meh
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
}
function getValidSelectableItems<T extends { id: string }>(
store: JotaiStore,
collapsedAtom: CollapsedAtom,
selectableItems: SelectableTreeNode<T>[],
) {
const collapsed = store.get(collapsedAtom);
return selectableItems.filter((i) => {
if (i.node.hidden) return false;
let p = i.node.parent;
while (p) {
if (collapsed[p.item.id]) return false;
p = p.parent;
}
return true;
});
}

View File

@@ -0,0 +1,31 @@
import { DragOverlay } from "@dnd-kit/core";
import { useAtomValue } from "jotai";
import { draggingIdsFamily } from "./atoms";
import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree";
import { TreeItemList } from "./TreeItemList";
export function TreeDragOverlay<T extends { id: string }>({
treeId,
selectableItems,
getItemKey,
ItemInner,
ItemLeftSlotInner,
}: {
treeId: string;
selectableItems: SelectableTreeNode<T>[];
} & Pick<TreeProps<T>, "getItemKey" | "ItemInner" | "ItemLeftSlotInner">) {
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
return (
<DragOverlay dropAnimation={null}>
<TreeItemList
treeId={`${treeId}.dragging`}
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
getItemKey={getItemKey}
ItemInner={ItemInner}
ItemLeftSlotInner={ItemLeftSlotInner}
forceDepth={0}
/>
</DragOverlay>
);
}

View File

@@ -0,0 +1,36 @@
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { memo } from "react";
import { DropMarker } from "../DropMarker";
import { hoveredParentDepthFamily, isIndexHoveredFamily } from "./atoms";
import type { TreeNode } from "./common";
import { useIsCollapsed } from "./context";
export const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
className,
treeId,
node,
index,
}: {
treeId: string;
index: number;
node: TreeNode<T> | null;
className?: string;
}) {
const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
const collapsed = useIsCollapsed(node?.item.id);
// Only show if we're hovering over this index
if (!isHovered) return null;
// Don't show if we're right under a collapsed folder, or empty folder. We have a separate
// delayed expansion animation for that.
if (collapsed || node?.children?.length === 0) return null;
return (
<div className="drop-marker relative" style={{ paddingLeft: `${parentDepth}rem` }}>
<DropMarker className={classNames(className)} />
</div>
);
});

View File

@@ -0,0 +1,32 @@
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { memo } from "react";
import { hoveredParentDepthFamily, isAncestorHoveredFamily } from "./atoms";
export const TreeIndentGuide = memo(function TreeIndentGuide({
treeId,
depth,
ancestorIds,
}: {
treeId: string;
depth: number;
ancestorIds: string[];
}) {
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
const isHovered = useAtomValue(isAncestorHoveredFamily({ treeId, ancestorIds }));
return (
<div className="flex">
{Array.from({ length: depth }).map((_, i) => (
<div
// oxlint-disable-next-line react/no-array-index-key
key={i}
className={classNames(
"w-[calc(1rem+0.5px)] border-r border-r-text-subtlest",
!(parentDepth === i + 1 && isHovered) && "opacity-30",
)}
/>
))}
</div>
);
});

View File

@@ -0,0 +1,398 @@
import type { DragMoveEvent } from "@dnd-kit/core";
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from "@dnd-kit/core";
import classNames from "classnames";
import { useAtomValue, useStore } from "jotai";
import type {
MouseEvent,
PointerEvent,
FocusEvent as ReactFocusEvent,
KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { computeSideForDragMove } from "../../lib/dnd";
import { Icon } from "../Icon";
import { isLastFocusedFamily, isSelectedFamily } from "./atoms";
import {
useCollapsedAtom,
useIsAncestorCollapsed,
useIsCollapsed,
useSetCollapsed,
} from "./context";
import type { TreeNode } from "./common";
import { getNodeKey } from "./common";
import type { TreeProps } from "./Tree";
import { TreeIndentGuide } from "./TreeIndentGuide";
export interface TreeItemClickEvent {
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}
export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>,
| "ItemInner"
| "ItemLeftSlotInner"
| "ItemRightSlot"
| "treeId"
| "getEditOptions"
| "getItemKey"
| "renderContextMenu"
> & {
node: TreeNode<T>;
className?: string;
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => unknown[] | Promise<unknown[]>;
depth: number;
setRef?: (item: T, n: TreeItemHandle | null) => void;
};
export interface TreeItemHandle {
rename: () => void;
isRenaming: boolean;
rect: () => DOMRect;
focus: () => void;
scrollIntoView: () => void;
}
const HOVER_CLOSED_FOLDER_DELAY = 800;
function TreeItem_<T extends { id: string }>({
treeId,
node,
ItemInner,
ItemLeftSlotInner,
ItemRightSlot,
getContextMenu,
renderContextMenu,
onClick,
getEditOptions,
className,
depth,
setRef,
}: TreeItemProps<T>) {
const store = useStore();
const listItemRef = useRef<HTMLLIElement>(null);
const draggableRef = useRef<HTMLButtonElement>(null);
const collapsedAtom = useCollapsedAtom();
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
const isCollapsed = useIsCollapsed(node.item.id);
const setCollapsed = useSetCollapsed(node.item.id);
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
const [editing, setEditing] = useState<boolean>(false);
const [dropHover, setDropHover] = useState<null | "drop" | "animate">(null);
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
const handle = useMemo<TreeItemHandle>(
() => ({
focus: () => {
draggableRef.current?.focus();
},
rename: () => {
if (getEditOptions != null) {
setEditing(true);
}
},
isRenaming: editing,
rect: () => {
if (listItemRef.current == null) {
return new DOMRect(0, 0, 0, 0);
}
return listItemRef.current.getBoundingClientRect();
},
scrollIntoView: () => {
listItemRef.current?.scrollIntoView({ block: "nearest" });
},
}),
[editing, getEditOptions],
);
useEffect(() => {
setRef?.(node.item, handle);
}, [setRef, handle, node.item]);
const ancestorIds = useMemo(() => {
const ids: string[] = [];
let p = node.parent;
while (p) {
ids.push(p.item.id);
p = p.parent;
}
return ids;
}, [node]);
const isAncestorCollapsed = useIsAncestorCollapsed(ancestorIds);
const [showContextMenu, setShowContextMenu] = useState<{
items: unknown[];
x: number;
y: number;
} | null>(null);
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => onClick?.(node.item, e),
[node, onClick],
);
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => !prev);
}, [setCollapsed]);
const handleSubmitNameEdit = useCallback(
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
setTimeout(() => setEditing(false), 200);
},
[getEditOptions, node.item, onClick],
);
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
el?.focus();
el?.select();
}, []);
const handleEditBlur = useCallback(
async function editBlur(e: ReactFocusEvent<HTMLInputElement>) {
await handleSubmitNameEdit(e.currentTarget);
},
[handleSubmitNameEdit],
);
const handleEditKeyDown = useCallback(
async (e: ReactKeyboardEvent<HTMLInputElement>) => {
e.stopPropagation(); // Don't trigger other tree keys (like arrows)
switch (e.key) {
case "Enter":
if (editing) {
e.preventDefault();
await handleSubmitNameEdit(e.currentTarget);
}
break;
case "Escape":
if (editing) {
e.preventDefault();
setEditing(false);
}
break;
}
},
[editing, handleSubmitNameEdit],
);
const handleDoubleClick = useCallback(() => {
const isFolder = node.children != null;
if (isFolder) {
toggleCollapsed();
} else if (getEditOptions != null) {
setEditing(true);
}
}, [getEditOptions, node.children, toggleCollapsed]);
const clearDropHover = () => {
if (startedHoverTimeout.current) {
clearTimeout(startedHoverTimeout.current);
startedHoverTimeout.current = undefined;
}
setDropHover(null);
};
const dndContext = useDndContext();
// Toggle auto-expand of folders when hovering over them
useDndMonitor({
onDragEnd() {
clearDropHover();
},
onDragMove(e: DragMoveEvent) {
const side = computeSideForDragMove(node.item.id, e);
const isFolder = node.children != null;
const hasChildren = (node.children?.length ?? 0) > 0;
const collapsedMap = store.get(collapsedAtom);
const itemCollapsed = !!collapsedMap[node.item.id];
if (itemCollapsed && isFolder && hasChildren && side === "after") {
setDropHover("animate");
clearTimeout(startedHoverTimeout.current);
startedHoverTimeout.current = setTimeout(() => {
store.set(collapsedAtom, { ...store.get(collapsedAtom), [node.item.id]: false });
clearDropHover();
// Force re-measure everything because all containers below the folder have been pushed down
requestAnimationFrame(() => {
dndContext.measureDroppableContainers(
dndContext.droppableContainers.toArray().map((c) => c.id),
);
});
}, HOVER_CLOSED_FOLDER_DELAY);
} else if (isFolder && !hasChildren && side === "after") {
setDropHover("drop");
} else {
clearDropHover();
}
},
});
const handleContextMenu = useCallback(
async (e: MouseEvent<HTMLElement>) => {
if (getContextMenu == null) return;
e.preventDefault();
e.stopPropagation();
// Set data attribute on the list item to preserve active state
if (listItemRef.current) {
listItemRef.current.setAttribute("data-context-menu-open", "true");
}
const items = await getContextMenu(node.item);
setShowContextMenu({ items, x: e.clientX ?? 100, y: e.clientY ?? 100 });
},
[getContextMenu, node.item],
);
const handleCloseContextMenu = useCallback(() => {
// Remove data attribute when context menu closes
if (listItemRef.current) {
listItemRef.current.removeAttribute("data-context-menu-open");
}
setShowContextMenu(null);
}, []);
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({ id: node.item.id, disabled: node.draggable === false || editing });
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
const handlePointerDown = useCallback(
function handlePointerDown(e: PointerEvent<HTMLButtonElement>) {
const handleByTree = e.metaKey || e.ctrlKey || e.shiftKey;
if (!handleByTree) {
listeners?.onPointerDown?.(e);
}
},
[listeners],
);
const handleSetDraggableRef = useCallback(
(node: HTMLButtonElement | null) => {
draggableRef.current = node;
setDraggableRef(node);
setDroppableRef(node);
},
[setDraggableRef, setDroppableRef],
);
if (node.hidden || isAncestorCollapsed) return null;
return (
<li
ref={listItemRef}
onContextMenu={handleContextMenu}
className={classNames(
className,
"tree-item",
"h-sm",
"grid grid-cols-[auto_minmax(0,1fr)]",
editing && "ring-1 focus-within:ring-focus",
dropHover != null && "relative z-10 ring-2 ring-primary",
dropHover === "animate" && "animate-blinkRing",
isSelected && "selected",
)}
>
<TreeIndentGuide treeId={treeId} depth={depth} ancestorIds={ancestorIds} />
<div
className={classNames(
"text-text-subtle",
"grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md",
)}
>
{showContextMenu &&
renderContextMenu?.({
items: showContextMenu.items,
position: showContextMenu,
onClose: handleCloseContextMenu,
})}
{node.children != null ? (
<button
type="button"
tabIndex={-1}
className="h-full pl-[0.5rem] outline-none"
onClick={toggleCollapsed}
>
<Icon
icon={node.children.length === 0 ? "dot" : "chevron_right"}
className={classNames(
"transition-transform text-text-subtlest",
"ml-auto",
"w-[1rem] h-[1rem]",
!isCollapsed && node.children.length > 0 && "rotate-90",
)}
/>
</button>
) : (
<span aria-hidden /> // Make the grid happy
)}
<button
ref={handleSetDraggableRef}
onPointerDown={handlePointerDown}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
disabled={editing}
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}
>
{ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}
{getEditOptions != null && editing ? (
(() => {
const { defaultValue, placeholder } = getEditOptions(node.item);
return (
<input
data-disable-hotkey
ref={handleEditFocus}
defaultValue={defaultValue}
placeholder={placeholder}
className="bg-transparent outline-none w-full cursor-text"
onBlur={handleEditBlur}
onKeyDown={handleEditKeyDown}
/>
);
})()
) : (
<ItemInner treeId={treeId} item={node.item} />
)}
</button>
{ItemRightSlot != null ? (
<ItemRightSlot treeId={treeId} item={node.item} />
) : (
<span aria-hidden />
)}
</div>
</li>
);
}
export const TreeItem = memo(
TreeItem_,
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
const nonEqualKeys = [];
for (const key of Object.keys(prevProps)) {
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
nonEqualKeys.push(key);
}
}
if (nonEqualKeys.length > 0) {
return false;
}
return (
getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey)
);
},
) as typeof TreeItem_;

View File

@@ -0,0 +1,55 @@
import type { CSSProperties } from "react";
import { Fragment } from "react";
import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree";
import { TreeDropMarker } from "./TreeDropMarker";
import type { TreeItemHandle, TreeItemProps } from "./TreeItem";
import { TreeItem } from "./TreeItem";
export type TreeItemListProps<T extends { id: string }> = Pick<
TreeProps<T>,
| "ItemInner"
| "ItemLeftSlotInner"
| "ItemRightSlot"
| "treeId"
| "getItemKey"
| "getEditOptions"
| "renderContextMenu"
> &
Pick<TreeItemProps<T>, "onClick" | "getContextMenu"> & {
nodes: SelectableTreeNode<T>[];
style?: CSSProperties;
className?: string;
forceDepth?: number;
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
};
export function TreeItemList<T extends { id: string }>({
className,
getItemKey,
nodes,
style,
treeId,
forceDepth,
addTreeItemRef,
...props
}: TreeItemListProps<T>) {
return (
<ul style={style} className={className}>
<TreeDropMarker node={null} treeId={treeId} index={0} />
{nodes.map((child, i) => (
<Fragment key={getItemKey(child.node.item)}>
<TreeItem
treeId={treeId}
setRef={addTreeItemRef}
node={child.node}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
{...props}
/>
<TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />
</Fragment>
))}
</ul>
);
}

View File

@@ -0,0 +1,75 @@
import { atom } from "jotai";
import { atomFamily, selectAtom } from "jotai/utils";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const selectedIdsFamily = atomFamily((_treeId: string) => {
return atom<string[]>([]);
});
export const isSelectedFamily = atomFamily(
({ treeId, itemId }: { treeId: string; itemId: string }) => {
return selectAtom(selectedIdsFamily(treeId), (ids) => ids.includes(itemId), Object.is);
},
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const focusIdsFamily = atomFamily((_treeId: string) => {
return atom<{ lastId: string | null; anchorId: string | null }>({ lastId: null, anchorId: null });
});
export const isLastFocusedFamily = atomFamily(
({ treeId, itemId }: { treeId: string; itemId: string }) =>
selectAtom(focusIdsFamily(treeId), (v) => v.lastId === itemId, Object.is),
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const draggingIdsFamily = atomFamily((_treeId: string) => {
return atom<string[]>([]);
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const hoveredParentFamily = atomFamily((_treeId: string) => {
return atom<{
index: number | null;
childIndex: number | null;
parentId: string | null;
parentDepth: number | null;
}>({
index: null,
childIndex: null,
parentId: null,
parentDepth: null,
});
});
export const isParentHoveredFamily = atomFamily(
({ treeId, parentId }: { treeId: string; parentId: string | null }) =>
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
);
export const isAncestorHoveredFamily = atomFamily(
({ treeId, ancestorIds }: { treeId: string; ancestorIds: string[] }) =>
selectAtom(
hoveredParentFamily(treeId),
(v) => v.parentId && ancestorIds.includes(v.parentId),
Object.is,
),
(a, b) => a.treeId === b.treeId && a.ancestorIds.join(",") === b.ancestorIds.join(","),
);
export const isIndexHoveredFamily = atomFamily(
({ treeId, index }: { treeId: string; index: number }) =>
selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is),
(a, b) => a.treeId === b.treeId && a.index === b.index,
);
export const hoveredParentDepthFamily = atomFamily((treeId: string) =>
selectAtom(
hoveredParentFamily(treeId),
(s) => s.parentDepth,
(a, b) => Object.is(a, b), // prevents re-render unless the value changes
),
);

View File

@@ -0,0 +1,100 @@
import type { createStore } from "jotai";
import type { ReactNode } from "react";
import type { CollapsedAtom } from "./context";
import { selectedIdsFamily } from "./atoms";
export type JotaiStore = ReturnType<typeof createStore>;
export type ContextMenuRenderer = (props: {
items: unknown[];
position: { x: number; y: number };
onClose: () => void;
}) => ReactNode;
export interface TreeNode<T extends { id: string }> {
children?: TreeNode<T>[];
item: T;
hidden?: boolean;
parent: TreeNode<T> | null;
depth: number;
draggable?: boolean;
localDrag?: boolean;
}
export interface SelectableTreeNode<T extends { id: string }> {
node: TreeNode<T>;
depth: number;
index: number;
}
export function getSelectedItems<T extends { id: string }>(
store: JotaiStore,
treeId: string,
selectableItems: SelectableTreeNode<T>[],
) {
const selectedItemIds = store.get(selectedIdsFamily(treeId));
return selectableItems
.filter((i) => selectedItemIds.includes(i.node.item.id))
.map((i) => i.node.item);
}
export function equalSubtree<T extends { id: string }>(
a: TreeNode<T>,
b: TreeNode<T>,
getItemKey: (t: T) => string,
): boolean {
if (getNodeKey(a, getItemKey) !== getNodeKey(b, getItemKey)) return false;
const ak = a.children ?? [];
const bk = b.children ?? [];
if (ak.length !== bk.length) {
return false;
}
for (let i = 0; i < ak.length; i++) {
// biome-ignore lint/style/noNonNullAssertion: none
if (!equalSubtree(ak[i]!, bk[i]!, getItemKey)) return false;
}
return true;
}
export function getNodeKey<T extends { id: string }>(a: TreeNode<T>, getItemKey: (i: T) => string) {
return getItemKey(a.item) + a.hidden;
}
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
if (node.parent == null) return false;
if (node.parent.item.id === ancestorId) return true;
// Check parents recursively
return hasAncestor(node.parent, ancestorId);
}
export function isVisibleNode<T extends { id: string }>(
store: JotaiStore,
collapsedAtom: CollapsedAtom,
node: TreeNode<T>,
) {
const collapsed = store.get(collapsedAtom);
let p = node.parent;
while (p) {
if (collapsed[p.item.id]) return false; // any collapsed ancestor hides this node
p = p.parent;
}
return true;
}
export function closestVisibleNode<T extends { id: string }>(
store: JotaiStore,
collapsedAtom: CollapsedAtom,
node: TreeNode<T>,
): TreeNode<T> | null {
let n: TreeNode<T> | null = node;
while (n) {
if (isVisibleNode(store, collapsedAtom, n) && !n.hidden) return n;
if (n.parent == null) return null;
n = n.parent;
}
return null;
}

View File

@@ -0,0 +1,60 @@
import type { WritableAtom } from "jotai";
import { useAtomValue, useStore } from "jotai";
import { selectAtom } from "jotai/utils";
import { createContext, useCallback, useContext, useMemo } from "react";
type CollapsedMap = Record<string, boolean>;
type SetAction = CollapsedMap | ((prev: CollapsedMap) => CollapsedMap);
export type CollapsedAtom = WritableAtom<CollapsedMap, [SetAction], void>;
export const CollapsedAtomContext = createContext<CollapsedAtom | null>(null);
export function useCollapsedAtom(): CollapsedAtom {
const atom = useContext(CollapsedAtomContext);
if (!atom) throw new Error("CollapsedAtomContext not provided");
return atom;
}
export function useIsCollapsed(itemId: string | undefined) {
const collapsedAtom = useCollapsedAtom();
const derivedAtom = useMemo(
() => selectAtom(collapsedAtom, (map) => !!map[itemId ?? "n/a"], Object.is),
[collapsedAtom, itemId],
);
return useAtomValue(derivedAtom);
}
export function useSetCollapsed(itemId: string | undefined) {
const collapsedAtom = useCollapsedAtom();
const store = useStore();
return useCallback(
(next: boolean | ((prev: boolean) => boolean)) => {
const key = itemId ?? "n/a";
const prevMap = store.get(collapsedAtom);
const prevValue = !!prevMap[key];
const value = typeof next === "function" ? next(prevValue) : next;
if (value === prevValue) return;
store.set(collapsedAtom, { ...prevMap, [key]: value });
},
[collapsedAtom, itemId, store],
);
}
export function useCollapsedMap() {
const collapsedAtom = useCollapsedAtom();
return useAtomValue(collapsedAtom);
}
export function useIsAncestorCollapsed(ancestorIds: string[]) {
const collapsedAtom = useCollapsedAtom();
const derivedAtom = useMemo(
() =>
selectAtom(
collapsedAtom,
(collapsed) => ancestorIds.some((id) => collapsed[id]),
(a, b) => a === b,
),
[collapsedAtom, ancestorIds],
);
return useAtomValue(derivedAtom);
}

View File

@@ -0,0 +1,30 @@
import { useMemo } from "react";
import type { SelectableTreeNode, TreeNode } from "./common";
export function useSelectableItems<T extends { id: string }>(root: TreeNode<T>) {
return useMemo(() => {
const selectableItems: SelectableTreeNode<T>[] = [];
// Put requests and folders into a tree structure
const next = (node: TreeNode<T>, depth = 0) => {
if (node.children == null) {
return;
}
// Recurse to children
let selectableIndex = 0;
for (const child of node.children) {
selectableItems.push({
node: child,
index: selectableIndex++,
depth,
});
next(child, depth + 1);
}
};
next(root);
return selectableItems;
}, [root]);
}

View File

@@ -0,0 +1,30 @@
import type { RefObject } from "react";
import { useLayoutEffect, useState } from "react";
export function useContainerSize(ref: RefObject<HTMLElement | null>) {
const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
useLayoutEffect(() => {
const el = ref.current;
if (el) {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === el) {
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
}
}
});
observer.observe(el);
return () => {
observer.unobserve(el);
observer.disconnect();
};
}
return undefined;
}, [ref]);
return size;
}

View File

@@ -0,0 +1,12 @@
import { debounce } from "@yaakapp-internal/lib";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useState } from "react";
export function useDebouncedState<T>(
defaultValue: T,
delay = 500,
): [T, Dispatch<SetStateAction<T>>, Dispatch<SetStateAction<T>>] {
const [state, setState] = useState<T>(defaultValue);
const debouncedSetState = useMemo(() => debounce(setState, delay), [delay]);
return [state, debouncedSetState, setState];
}

View File

@@ -0,0 +1,8 @@
import { useEffect } from "react";
import { useDebouncedState } from "./useDebouncedState";
export function useDebouncedValue<T>(value: T, delay = 500) {
const [state, setState] = useDebouncedState<T>(value, delay);
useEffect(() => setState(value), [setState, value]);
return state;
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useWindowSize } from "react-use";
import { useDebouncedValue } from "./useDebouncedValue";
export function useIsFullscreen() {
const windowSize = useWindowSize();
const debouncedWindowWidth = useDebouncedValue(windowSize.width);
// NOTE: Fullscreen state isn't updated right after resize event on Mac (needs to wait for animation) so
// we'll wait for a bit using the debounced window size. Hopefully Tauri eventually adds a way to listen
// for fullscreen change events.
return (
useQuery({
queryKey: ["is_fullscreen", debouncedWindowWidth],
queryFn: async () => {
return getCurrentWebviewWindow().isFullscreen();
},
}).data ?? false
);
}

View File

@@ -0,0 +1,26 @@
import { useRef } from "react";
const PORTAL_CONTAINER_ID = "react-portal";
export function usePortal(name: string) {
const ref = useRef(getOrCreatePortal(name));
return ref.current;
}
function getOrCreatePortal(name: string) {
let portalContainer = document.getElementById(PORTAL_CONTAINER_ID);
if (!portalContainer) {
portalContainer = document.createElement("div");
portalContainer.id = PORTAL_CONTAINER_ID;
document.body.appendChild(portalContainer);
}
let existing = portalContainer.querySelector(`:scope > [data-portal-name="${name}"]`);
if (!existing) {
const el = document.createElement("div");
el.setAttribute("data-portal-name", name);
portalContainer.appendChild(el);
existing = el;
}
return existing;
}

View File

@@ -0,0 +1,19 @@
import { useRef, useState } from "react";
import { useUnmount } from "react-use";
/** Returns a boolean that is true for a given number of milliseconds. */
export function useTimedBoolean(millis = 1500): [boolean, () => void] {
const [value, setValue] = useState(false);
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const reset = () => timeout.current && clearTimeout(timeout.current);
useUnmount(reset);
const setToTrue = () => {
setValue(true);
reset();
timeout.current = setTimeout(() => setValue(false), millis);
};
return [value, setToTrue];
}

48
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,48 @@
export type { BannerProps } from "./components/Banner";
export { Banner } from "./components/Banner";
export type { ButtonProps } from "./components/Button";
export { Button } from "./components/Button";
export { DropMarker } from "./components/DropMarker";
export { HeaderSize } from "./components/HeaderSize";
export { Heading } from "./components/Heading";
export type { IconProps } from "./components/Icon";
export { Icon } from "./components/Icon";
export type { IconButtonProps } from "./components/IconButton";
export { IconButton } from "./components/IconButton";
export { FormattedError } from "./components/FormattedError";
export { InlineCode } from "./components/InlineCode";
export { LoadingIcon } from "./components/LoadingIcon";
export { Overlay } from "./components/Overlay";
export { Portal } from "./components/Portal";
export type { ResizeHandleEvent } from "./components/ResizeHandle";
export { ResizeHandle } from "./components/ResizeHandle";
export { SidebarLayout } from "./components/SidebarLayout";
export type { SlotProps, SplitLayoutLayout } from "./components/SplitLayout";
export { SplitLayout } from "./components/SplitLayout";
export type { VStackProps } from "./components/Stacks";
export { HStack, VStack } from "./components/Stacks";
export {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from "./components/Table";
export { isSelectedFamily, selectedIdsFamily } from "./components/tree/atoms";
export type { TreeNode } from "./components/tree/common";
export type { TreeHandle, TreeProps } from "./components/tree/Tree";
export { Tree } from "./components/tree/Tree";
export type { TreeItemProps } from "./components/tree/TreeItem";
export { WindowControls } from "./components/WindowControls";
export { useContainerSize } from "./hooks/useContainerSize";
export { useDebouncedState } from "./hooks/useDebouncedState";
export { useDebouncedValue } from "./hooks/useDebouncedValue";
export { useIsFullscreen } from "./hooks/useIsFullscreen";
export { usePortal } from "./hooks/usePortal";
export { useTimedBoolean } from "./hooks/useTimedBoolean";
export { clamp } from "./lib/clamp";
export { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "./lib/constants";
export { computeSideForDragMove } from "./lib/dnd";
export { minPromiseMillis } from "./lib/minPromiseMillis";

View File

@@ -0,0 +1,3 @@
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

View File

@@ -0,0 +1,4 @@
export const HEADER_SIZE_MD = "30px";
export const HEADER_SIZE_LG = "40px";
export const WINDOW_CONTROLS_WIDTH = "10.5rem";

View File

@@ -0,0 +1,39 @@
import type { DragMoveEvent } from "@dnd-kit/core";
export function computeSideForDragMove(
id: string,
e: DragMoveEvent,
orientation: "vertical" | "horizontal" = "vertical",
): "before" | "after" | null {
if (e.over == null || e.over.id !== id) {
return null;
}
if (e.active.rect.current.initial == null) return null;
const overRect = e.over.rect;
if (orientation === "horizontal") {
// For horizontal layouts (tabs side-by-side), use left/right logic
const activeLeft =
e.active.rect.current.translated?.left ?? e.active.rect.current.initial.left + e.delta.x;
const pointerX = activeLeft + e.active.rect.current.initial.width / 2;
const hoverLeft = overRect.left;
const hoverRight = overRect.right;
const hoverMiddleX = hoverLeft + (hoverRight - hoverLeft) / 2;
return pointerX < hoverMiddleX ? "before" : "after"; // 'before' = left, 'after' = right
} else {
// For vertical layouts, use top/bottom logic
const activeTop =
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
const hoverTop = overRect.top;
const hoverBottom = overRect.bottom;
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
const hoverClientY = pointerY - hoverTop;
return hoverClientY < hoverMiddleY ? "before" : "after";
}
}

View File

@@ -0,0 +1,16 @@
export async function minPromiseMillis<T>(promise: Promise<T>, millis = 300): Promise<T> {
const start = Date.now();
let result: T;
try {
result = await promise;
} catch (e) {
const remaining = millis - (Date.now() - start);
if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
throw e;
}
const remaining = millis - (Date.now() - start);
if (remaining > 0) await new Promise((r) => setTimeout(r, remaining));
return result;
}

14
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"strict": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}