[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
145
components/ui/FixedSizeVirtualList.tsx
Normal file
145
components/ui/FixedSizeVirtualList.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { getFixedSizeVirtualWindow } from './virtualListMath';
|
||||
|
||||
const DEFAULT_OVERSCAN = 6;
|
||||
|
||||
export type FixedSizeVirtualListHandle = {
|
||||
scrollToIndex: (index: number, align?: 'auto' | 'center') => void;
|
||||
};
|
||||
|
||||
interface FixedSizeVirtualListProps<T> {
|
||||
items: T[];
|
||||
itemHeight: number;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
overscan?: number;
|
||||
getItemKey: (item: T, index: number) => string;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
function FixedSizeVirtualListInner<T>(
|
||||
{
|
||||
items,
|
||||
itemHeight,
|
||||
className,
|
||||
contentClassName,
|
||||
overscan = DEFAULT_OVERSCAN,
|
||||
getItemKey,
|
||||
renderItem,
|
||||
}: FixedSizeVirtualListProps<T>,
|
||||
ref: React.ForwardedRef<FixedSizeVirtualListHandle>,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
setViewportHeight(container.clientHeight);
|
||||
};
|
||||
|
||||
updateViewportHeight();
|
||||
const observer = new ResizeObserver(updateViewportHeight);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
startIndex,
|
||||
endIndex,
|
||||
effectiveScrollTop,
|
||||
totalHeight,
|
||||
} = getFixedSizeVirtualWindow({
|
||||
itemCount: items.length,
|
||||
itemHeight,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
overscan,
|
||||
});
|
||||
|
||||
// Sync the DOM scroll position when content shrinks (filter / data change).
|
||||
// Layout already uses effectiveScrollTop so the list never blanks for a frame.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (container.scrollTop !== effectiveScrollTop) {
|
||||
container.scrollTop = effectiveScrollTop;
|
||||
}
|
||||
if (scrollTop !== effectiveScrollTop) {
|
||||
setScrollTop(effectiveScrollTop);
|
||||
}
|
||||
}, [effectiveScrollTop, scrollTop]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToIndex: (index: number, align = 'auto') => {
|
||||
const container = containerRef.current;
|
||||
if (!container || index < 0 || index >= items.length) return;
|
||||
|
||||
const itemTop = index * itemHeight;
|
||||
const itemBottom = itemTop + itemHeight;
|
||||
const viewTop = container.scrollTop;
|
||||
const viewBottom = viewTop + container.clientHeight;
|
||||
|
||||
if (align === 'center') {
|
||||
container.scrollTop = Math.max(
|
||||
0,
|
||||
itemTop - (container.clientHeight - itemHeight) / 2,
|
||||
);
|
||||
} else if (itemTop < viewTop) {
|
||||
container.scrollTop = itemTop;
|
||||
} else if (itemBottom > viewBottom) {
|
||||
container.scrollTop = itemBottom - container.clientHeight;
|
||||
}
|
||||
setScrollTop(container.scrollTop);
|
||||
},
|
||||
}), [itemHeight, items.length]);
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
setScrollTop(event.currentTarget.scrollTop);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('h-full overflow-y-auto overflow-x-hidden', className)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
className={cn('relative w-full', contentClassName)}
|
||||
style={{ height: totalHeight || undefined, minHeight: items.length === 0 ? 0 : totalHeight }}
|
||||
>
|
||||
{items.slice(startIndex, endIndex).map((item, offset) => {
|
||||
const index = startIndex + offset;
|
||||
return (
|
||||
<div
|
||||
key={getItemKey(item, index)}
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: index * itemHeight,
|
||||
height: itemHeight,
|
||||
}}
|
||||
>
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FixedSizeVirtualList = forwardRef(FixedSizeVirtualListInner) as <T>(
|
||||
props: FixedSizeVirtualListProps<T> & { ref?: React.ForwardedRef<FixedSizeVirtualListHandle> },
|
||||
) => React.ReactElement | null;
|
||||
178
components/ui/VariableSizeVirtualList.tsx
Normal file
178
components/ui/VariableSizeVirtualList.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { clampScrollTop } from './virtualListMath';
|
||||
|
||||
const DEFAULT_OVERSCAN = 6;
|
||||
|
||||
export type VariableSizeVirtualListHandle = {
|
||||
scrollToIndex: (index: number, align?: 'auto' | 'center') => void;
|
||||
};
|
||||
|
||||
interface VariableSizeVirtualListProps<T> {
|
||||
items: T[];
|
||||
getItemHeight: (item: T, index: number) => number;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
overscan?: number;
|
||||
getItemKey: (item: T, index: number) => string;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
function VariableSizeVirtualListInner<T>(
|
||||
{
|
||||
items,
|
||||
getItemHeight,
|
||||
className,
|
||||
contentClassName,
|
||||
overscan = DEFAULT_OVERSCAN,
|
||||
getItemKey,
|
||||
renderItem,
|
||||
}: VariableSizeVirtualListProps<T>,
|
||||
ref: React.ForwardedRef<VariableSizeVirtualListHandle>,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const offsets: number[] = [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
offsets.push(total);
|
||||
total += getItemHeight(items[i], i);
|
||||
}
|
||||
return { offsets, totalHeight: total };
|
||||
}, [getItemHeight, items]);
|
||||
|
||||
const effectiveScrollTop = clampScrollTop(
|
||||
scrollTop,
|
||||
layout.totalHeight,
|
||||
viewportHeight,
|
||||
);
|
||||
|
||||
// Sync DOM when content shrinks; render path already uses effectiveScrollTop.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (container.scrollTop !== effectiveScrollTop) {
|
||||
container.scrollTop = effectiveScrollTop;
|
||||
}
|
||||
if (scrollTop !== effectiveScrollTop) {
|
||||
setScrollTop(effectiveScrollTop);
|
||||
}
|
||||
}, [effectiveScrollTop, scrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateViewportHeight = () => {
|
||||
setViewportHeight(container.clientHeight);
|
||||
};
|
||||
|
||||
updateViewportHeight();
|
||||
const observer = new ResizeObserver(updateViewportHeight);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToIndex: (index: number, align = 'auto') => {
|
||||
const container = containerRef.current;
|
||||
if (!container || index < 0 || index >= items.length) return;
|
||||
|
||||
const itemTop = layout.offsets[index] ?? 0;
|
||||
const itemHeight = getItemHeight(items[index], index);
|
||||
const itemBottom = itemTop + itemHeight;
|
||||
const viewTop = container.scrollTop;
|
||||
const viewBottom = viewTop + container.clientHeight;
|
||||
|
||||
if (align === 'center') {
|
||||
container.scrollTop = Math.max(
|
||||
0,
|
||||
itemTop - (container.clientHeight - itemHeight) / 2,
|
||||
);
|
||||
} else if (itemTop < viewTop) {
|
||||
container.scrollTop = itemTop;
|
||||
} else if (itemBottom > viewBottom) {
|
||||
container.scrollTop = itemBottom - container.clientHeight;
|
||||
}
|
||||
setScrollTop(container.scrollTop);
|
||||
},
|
||||
}), [getItemHeight, items, layout.offsets]);
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
setScrollTop(event.currentTarget.scrollTop);
|
||||
}, []);
|
||||
|
||||
const { startIndex, endIndex } = useMemo(() => {
|
||||
if (items.length === 0) {
|
||||
return { startIndex: 0, endIndex: 0 };
|
||||
}
|
||||
|
||||
const { offsets } = layout;
|
||||
|
||||
// First visible row: largest index whose top <= effectiveScrollTop.
|
||||
let lo = 0;
|
||||
let hi = items.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2);
|
||||
if ((offsets[mid] ?? 0) <= effectiveScrollTop) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
const start = Math.max(0, lo - overscan);
|
||||
|
||||
const viewBottom = effectiveScrollTop + viewportHeight;
|
||||
let scan = start;
|
||||
while (scan < items.length && (offsets[scan] ?? 0) < viewBottom + overscan * 40) {
|
||||
scan += 1;
|
||||
}
|
||||
const end = Math.min(items.length, scan + overscan);
|
||||
|
||||
return { startIndex: start, endIndex: end };
|
||||
}, [effectiveScrollTop, items.length, layout, overscan, viewportHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('h-full overflow-y-auto overflow-x-hidden', className)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
className={cn('relative w-full', contentClassName)}
|
||||
style={{
|
||||
height: layout.totalHeight || undefined,
|
||||
minHeight: items.length === 0 ? 0 : layout.totalHeight,
|
||||
}}
|
||||
>
|
||||
{items.slice(startIndex, endIndex).map((item, offset) => {
|
||||
const index = startIndex + offset;
|
||||
const top = layout.offsets[index] ?? 0;
|
||||
const height = getItemHeight(item, index);
|
||||
return (
|
||||
<div
|
||||
key={getItemKey(item, index)}
|
||||
className="absolute left-0 right-0"
|
||||
style={{ top, height }}
|
||||
>
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const VariableSizeVirtualList = forwardRef(VariableSizeVirtualListInner) as <T>(
|
||||
props: VariableSizeVirtualListProps<T> & { ref?: React.ForwardedRef<VariableSizeVirtualListHandle> },
|
||||
) => React.ReactElement | null;
|
||||
450
components/ui/aside-panel.tsx
Normal file
450
components/ui/aside-panel.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
|
||||
export const DEFAULT_ASIDE_INLINE_WIDTH_PX = 380;
|
||||
const MIN_ASIDE_INLINE_WIDTH_PX = 320;
|
||||
const MAX_ASIDE_INLINE_WIDTH_PX = 720;
|
||||
|
||||
export function clampAsideInlineWidth(width: number): number {
|
||||
return Math.max(MIN_ASIDE_INLINE_WIDTH_PX, Math.min(MAX_ASIDE_INLINE_WIDTH_PX, width));
|
||||
}
|
||||
|
||||
export interface AsidePanelResizeProps {
|
||||
resizable?: boolean;
|
||||
persistWidthStorageKey?: string;
|
||||
resizeAriaLabel?: string;
|
||||
}
|
||||
|
||||
function parseInlineWidthPx(width: string): number {
|
||||
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
|
||||
if (arbitraryWidthMatch) {
|
||||
const raw = arbitraryWidthMatch[1].trim();
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isNaN(parsed)) return clampAsideInlineWidth(parsed);
|
||||
}
|
||||
|
||||
switch (width) {
|
||||
case 'w-full':
|
||||
case 'w-screen':
|
||||
return DEFAULT_ASIDE_INLINE_WIDTH_PX;
|
||||
default:
|
||||
return DEFAULT_ASIDE_INLINE_WIDTH_PX;
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedAsideWidth(storageKey: string | undefined, fallback: number): number {
|
||||
if (!storageKey) return fallback;
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
return clampAsideInlineWidth(stored);
|
||||
}
|
||||
|
||||
// Types
|
||||
interface AsideContentItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface AsidePanelContextType {
|
||||
push: (item: AsideContentItem) => void;
|
||||
pop: () => void;
|
||||
replace: (item: AsideContentItem) => void;
|
||||
clear: () => void;
|
||||
canGoBack: boolean;
|
||||
currentItem: AsideContentItem | null;
|
||||
}
|
||||
|
||||
const AsidePanelContext = createContext<AsidePanelContextType | null>(null);
|
||||
const AsideActionMenuContext = createContext<(() => void) | null>(null);
|
||||
|
||||
export const useAsidePanel = () => {
|
||||
const context = useContext(AsidePanelContext);
|
||||
if (!context) {
|
||||
throw new Error('useAsidePanel must be used within an AsidePanel');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Props
|
||||
interface AsidePanelProps extends AsidePanelResizeProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
showBackButton?: boolean;
|
||||
onBack?: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
* Optional stable identifier emitted as `data-section` on the panel
|
||||
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
|
||||
*/
|
||||
dataSection?: string;
|
||||
}
|
||||
|
||||
interface AsidePanelHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
onBack?: () => void;
|
||||
onClose: () => void;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
// Header Component
|
||||
export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
onBack,
|
||||
onClose,
|
||||
showBackButton = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{showBackButton && onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold truncate">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground truncate">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{actions}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Content Component (wraps children with scroll)
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
// Footer Component
|
||||
export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: string }> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("px-4 py-3 border-t border-border/60 shrink-0", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Action Menu Component (for the ... button)
|
||||
interface AsideActionMenuProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="end">
|
||||
<AsideActionMenuContext.Provider value={close}>
|
||||
{children}
|
||||
</AsideActionMenuContext.Provider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const invokeAsideActionMenuItemClick = (
|
||||
closeMenu: (() => void) | null,
|
||||
onClick?: () => void,
|
||||
) => {
|
||||
closeMenu?.();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// Action Menu Item
|
||||
export const AsideActionMenuItem: React.FC<{
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: 'default' | 'destructive';
|
||||
}> = ({ icon, children, onClick, variant = 'default' }) => {
|
||||
const closeMenu = useContext(AsideActionMenuContext);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => invokeAsideActionMenuItemClick(closeMenu, onClick)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer",
|
||||
variant === 'destructive'
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Panel Component with Stack Support
|
||||
interface AsidePanelStackProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialItem: AsideContentItem;
|
||||
className?: string;
|
||||
width?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
/**
|
||||
* Optional stable identifier emitted as `data-section` on the panel
|
||||
* root. Used as a targeting hook for Custom CSS.
|
||||
*/
|
||||
dataSection?: string;
|
||||
}
|
||||
|
||||
export type AsidePanelLayout = 'overlay' | 'inline';
|
||||
|
||||
const resolveInlineWidth = (width: string) => {
|
||||
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
|
||||
if (arbitraryWidthMatch) {
|
||||
return arbitraryWidthMatch[1];
|
||||
}
|
||||
|
||||
switch (width) {
|
||||
case 'w-full':
|
||||
return '100%';
|
||||
case 'w-screen':
|
||||
return '100vw';
|
||||
default:
|
||||
return '380px';
|
||||
}
|
||||
};
|
||||
|
||||
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
initialItem,
|
||||
className,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
dataSection,
|
||||
}) => {
|
||||
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
|
||||
|
||||
const push = useCallback((item: AsideContentItem) => {
|
||||
setStack(prev => [...prev, item]);
|
||||
}, []);
|
||||
|
||||
const pop = useCallback(() => {
|
||||
setStack(prev => {
|
||||
if (prev.length > 1) {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((item: AsideContentItem) => {
|
||||
setStack([item]);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setStack([initialItem]);
|
||||
}, [initialItem]);
|
||||
|
||||
const currentItem = stack[stack.length - 1];
|
||||
const canGoBack = stack.length > 1;
|
||||
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
|
||||
const inlineStyle = layout === 'inline'
|
||||
? ({
|
||||
width: inlineWidth,
|
||||
['--aside-inline-width' as string]: inlineWidth,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
// Reset stack when panel closes/opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setStack([initialItem]);
|
||||
}
|
||||
}, [open, initialItem]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
|
||||
<div className={cn(
|
||||
layout === 'inline'
|
||||
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && width,
|
||||
className
|
||||
)}
|
||||
style={inlineStyle}
|
||||
data-section={dataSection}>
|
||||
<AsidePanelHeader
|
||||
title={currentItem.title}
|
||||
subtitle={currentItem.subtitle}
|
||||
actions={currentItem.actions}
|
||||
onBack={canGoBack ? pop : undefined}
|
||||
onClose={onClose}
|
||||
showBackButton={canGoBack}
|
||||
/>
|
||||
{currentItem.content}
|
||||
</div>
|
||||
</AsidePanelContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple Panel Component (no stack)
|
||||
export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
showBackButton,
|
||||
onBack,
|
||||
children,
|
||||
className,
|
||||
width = 'w-[380px]',
|
||||
layout = 'overlay',
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
dataSection,
|
||||
}) => {
|
||||
const fallbackWidthPx = parseInlineWidthPx(width);
|
||||
const [panelWidthPx, setPanelWidthPx] = useState(() =>
|
||||
resizable ? readPersistedAsideWidth(persistWidthStorageKey, fallbackWidthPx) : fallbackWidthPx,
|
||||
);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const effectivePanelWidthPx = resizable ? panelWidthPx : fallbackWidthPx;
|
||||
// Resizable panels always use pixel width so overlay and inline share the same drag path.
|
||||
const usesPixelWidth = resizable || layout === 'inline';
|
||||
|
||||
const panelStyle = usesPixelWidth
|
||||
? ({
|
||||
width: `${effectivePanelWidthPx}px`,
|
||||
['--aside-inline-width' as string]: `${effectivePanelWidthPx}px`,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!resizable) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = panelWidthPx;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
|
||||
setIsResizing(true);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
setPanelWidthPx(clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
|
||||
};
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
const nextWidth = clampAsideInlineWidth(startWidth + startX - upEvent.clientX);
|
||||
setPanelWidthPx(nextWidth);
|
||||
if (persistWidthStorageKey) {
|
||||
localStorageAdapter.writeNumber(persistWidthStorageKey, nextWidth);
|
||||
}
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
}, [panelWidthPx, persistWidthStorageKey, resizable]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
layout === 'inline'
|
||||
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
|
||||
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
layout === 'overlay' && !usesPixelWidth && width,
|
||||
isResizing && 'transition-none',
|
||||
className
|
||||
)}
|
||||
style={panelStyle}
|
||||
data-section={dataSection}>
|
||||
{resizable ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={resizeAriaLabel}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-40 h-full w-2 -translate-x-1/2 cursor-col-resize',
|
||||
'after:absolute after:left-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:-translate-x-1/2 after:bg-border/0 after:transition-colors',
|
||||
'hover:after:bg-border/70',
|
||||
isResizing && 'after:bg-primary/70',
|
||||
)}
|
||||
onPointerDown={handleResizeStart}
|
||||
/>
|
||||
) : null}
|
||||
{title && (
|
||||
<AsidePanelHeader
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
showBackButton={showBackButton}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AsidePanel;
|
||||
28
components/ui/badge.tsx
Normal file
28
components/ui/badge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
variant?: "default" | "secondary" | "destructive" | "outline"
|
||||
}
|
||||
|
||||
function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none",
|
||||
{
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80": variant === "default",
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80": variant === "destructive",
|
||||
"text-foreground": variant === "outline",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge }
|
||||
38
components/ui/button.tsx
Normal file
38
components/ui/button.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
|
||||
{
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90": variant === "default",
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90": variant === "destructive",
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground": variant === "outline",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
|
||||
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
|
||||
"text-primary underline-offset-4 hover:underline": variant === "link",
|
||||
"h-10 px-4 py-2": size === "default",
|
||||
"h-9 rounded-md px-3": size === "sm",
|
||||
"h-11 rounded-md px-8": size === "lg",
|
||||
"h-10 w-10": size === "icon",
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button }
|
||||
19
components/ui/card.tsx
Normal file
19
components/ui/card.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
export { Card }
|
||||
9
components/ui/collapsible.tsx
Normal file
9
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible,CollapsibleContent,CollapsibleTrigger }
|
||||
837
components/ui/combobox.tsx
Normal file
837
components/ui/combobox.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
import { Check, ChevronDown, Plus, X } from "lucide-react"
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
icon?: React.ReactNode;
|
||||
labelStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
allowCreate?: boolean;
|
||||
onCreateNew?: (value: string) => void;
|
||||
createText?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
inputStyle?: React.CSSProperties;
|
||||
onInputValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
selectValueOnFocus?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const comboboxWheelDeltaToPixels = (deltaY: number, deltaMode: number): number => {
|
||||
if (deltaMode === 1) return deltaY * 16
|
||||
if (deltaMode === 2) return deltaY * 280
|
||||
return deltaY
|
||||
}
|
||||
|
||||
export type ComboboxScrollableTarget = {
|
||||
clientHeight: number;
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
export const filterComboboxOptions = (
|
||||
options: ComboboxOption[],
|
||||
inputValue: string,
|
||||
isSearching: boolean,
|
||||
): ComboboxOption[] => {
|
||||
if (!isSearching || !inputValue.trim()) return options
|
||||
const lower = inputValue.trim().toLowerCase()
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.label.toLowerCase().includes(lower) ||
|
||||
option.value.toLowerCase().includes(lower) ||
|
||||
option.sublabel?.toLowerCase().includes(lower)
|
||||
)
|
||||
}
|
||||
|
||||
export const applyComboboxWheelScroll = (
|
||||
target: ComboboxScrollableTarget,
|
||||
deltaY: number,
|
||||
deltaMode: number,
|
||||
): boolean => {
|
||||
if (target.scrollHeight <= target.clientHeight) return false
|
||||
|
||||
target.scrollTop += comboboxWheelDeltaToPixels(deltaY, deltaMode)
|
||||
return true
|
||||
}
|
||||
|
||||
export const getNextComboboxActiveIndex = (
|
||||
currentIndex: number,
|
||||
optionCount: number,
|
||||
direction: 1 | -1,
|
||||
): number => {
|
||||
if (optionCount <= 0) return -1
|
||||
if (currentIndex < 0 || currentIndex >= optionCount) {
|
||||
return direction === 1 ? 0 : optionCount - 1
|
||||
}
|
||||
return (currentIndex + direction + optionCount) % optionCount
|
||||
}
|
||||
|
||||
export type ComboboxFocusableInput = Pick<HTMLInputElement, "focus" | "select">;
|
||||
|
||||
export const focusComboboxInput = (
|
||||
input: ComboboxFocusableInput | null,
|
||||
selectValue: boolean,
|
||||
): void => {
|
||||
input?.focus()
|
||||
if (selectValue) input?.select()
|
||||
}
|
||||
|
||||
export const selectComboboxInputIfFocused = (
|
||||
input: ComboboxFocusableInput | null,
|
||||
activeElement: Element | null,
|
||||
): void => {
|
||||
if (input && input === activeElement) input.select()
|
||||
}
|
||||
|
||||
export const canComboboxOpen = (disabled: boolean, nextOpen: boolean): boolean =>
|
||||
!disabled || !nextOpen
|
||||
|
||||
/**
|
||||
* Incremental rendering limits for large option lists (e.g. the font pickers
|
||||
* can list hundreds of locally installed fonts). Rendering every option at
|
||||
* once freezes the UI, so we mount an initial slice and grow the window as
|
||||
* the user scrolls deeper, and slide the bounded window when arrow
|
||||
* navigation jumps to an option beyond it.
|
||||
*/
|
||||
export const COMBOBOX_INITIAL_RENDER_LIMIT = 60
|
||||
export const COMBOBOX_RENDER_LIMIT_STEP = 120
|
||||
const COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX = 80
|
||||
|
||||
export const comboboxNextRenderLimit = (
|
||||
currentLimit: number,
|
||||
optionCount: number,
|
||||
): number | null => {
|
||||
if (optionCount <= currentLimit) return null
|
||||
return Math.min(currentLimit + COMBOBOX_RENDER_LIMIT_STEP, optionCount)
|
||||
}
|
||||
|
||||
export const shouldExpandComboboxWindow = (
|
||||
target: ComboboxScrollableTarget,
|
||||
renderedCount: number,
|
||||
optionCount: number,
|
||||
): boolean => {
|
||||
if (renderedCount >= optionCount) return false
|
||||
const distanceFromBottom = target.scrollHeight - (target.scrollTop + target.clientHeight)
|
||||
return distanceFromBottom <= COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX
|
||||
}
|
||||
|
||||
export const shouldResetComboboxWindow = (target: ComboboxScrollableTarget): boolean =>
|
||||
target.scrollTop <= COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX
|
||||
|
||||
/**
|
||||
* Keyboard navigation can land beyond the mounted window (e.g. ArrowUp wraps
|
||||
* straight to the last option). Instead of growing the prefix until that
|
||||
* index is mounted — which mounts every row for large lists — slide the
|
||||
* bounded window so the destination sits at its edge. Returns the new window
|
||||
* start, or null when the active option is already mounted.
|
||||
*/
|
||||
export const comboboxWindowStartForActiveIndex = (
|
||||
activeOptionIndex: number,
|
||||
windowStart: number,
|
||||
renderLimit: number,
|
||||
optionCount: number,
|
||||
): number | null => {
|
||||
if (optionCount <= 0 || activeOptionIndex < 0) return null
|
||||
if (activeOptionIndex < windowStart) return activeOptionIndex
|
||||
if (activeOptionIndex >= windowStart + renderLimit) {
|
||||
return Math.max(0, Math.min(activeOptionIndex - renderLimit + 1, optionCount - renderLimit))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ComboboxOptionsList({
|
||||
children,
|
||||
id,
|
||||
listbox = false,
|
||||
onScrollCapture,
|
||||
scrollRef,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
listbox?: boolean;
|
||||
onScrollCapture?: React.UIEventHandler<HTMLDivElement>;
|
||||
scrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const handleWheelCapture = (event: React.WheelEvent<HTMLDivElement>) => {
|
||||
const handled = applyComboboxWheelScroll(event.currentTarget, event.deltaY, event.deltaMode)
|
||||
if (!handled) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
ref={scrollRef}
|
||||
role={listbox ? "listbox" : undefined}
|
||||
className="max-h-[280px] overflow-y-auto overscroll-contain p-1"
|
||||
onWheelCapture={handleWheelCapture}
|
||||
onScrollCapture={onScrollCapture}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Select...",
|
||||
emptyText = "No results found",
|
||||
allowCreate = false,
|
||||
onCreateNew,
|
||||
createText = "Create",
|
||||
icon,
|
||||
className,
|
||||
triggerClassName,
|
||||
inputStyle,
|
||||
onInputValueChange,
|
||||
disabled = false,
|
||||
clearable = true,
|
||||
selectValueOnFocus = false,
|
||||
ariaLabel,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const [activeIndex, setActiveIndex] = React.useState(-1)
|
||||
// Track if user is actively searching (typed something after opening)
|
||||
const [isSearching, setIsSearching] = React.useState(false)
|
||||
// Incremental rendering window for very large option lists
|
||||
const [renderLimit, setRenderLimit] = React.useState(COMBOBOX_INITIAL_RENDER_LIMIT)
|
||||
// First mounted option index, so keyboard jumps slide the window instead
|
||||
// of mounting the entire prefix of a large list.
|
||||
const [windowStart, setWindowStart] = React.useState(0)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const wasOpenRef = React.useRef(false)
|
||||
const activeOptionRef = React.useRef<HTMLButtonElement>(null)
|
||||
const optionsScrollRef = React.useRef<HTMLDivElement>(null)
|
||||
// True while scroll events on the options list are caused by this
|
||||
// component scrolling the active option into view (keyboard navigation,
|
||||
// mouse hover) rather than by the user scrolling manually.
|
||||
const navigationalScrollRef = React.useRef(false)
|
||||
const listboxId = React.useId()
|
||||
|
||||
// Sync input value with external value when not focused
|
||||
React.useEffect(() => {
|
||||
const wasOpen = wasOpenRef.current
|
||||
wasOpenRef.current = open
|
||||
|
||||
if (!open) {
|
||||
const selected = options.find((opt) => opt.value === value)
|
||||
setInputValue(selected?.label || value || "")
|
||||
setIsSearching(false)
|
||||
|
||||
if (wasOpen && selectValueOnFocus) {
|
||||
// The restored label lands after the close event. Reselect it on the next
|
||||
// frame so the next keystroke replaces it instead of appending to it.
|
||||
requestAnimationFrame(() => {
|
||||
selectComboboxInputIfFocused(
|
||||
inputRef.current,
|
||||
typeof document === "undefined" ? null : document.activeElement,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [value, options, open, selectValueOnFocus])
|
||||
|
||||
// Show all options when dropdown is open but user hasn't started searching
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
return filterComboboxOptions(options, inputValue, isSearching)
|
||||
}, [options, inputValue, isSearching])
|
||||
|
||||
// Restart the window from its initial size whenever the picker opens or
|
||||
// the filtered result set changes (option set, search mode, or query), so
|
||||
// a previously grown or slid window never persists across a changed list.
|
||||
// The reset happens during render (React discards the render output and
|
||||
// re-renders immediately after a render-phase state update), so a changed
|
||||
// result set is never committed with a stale, oversized window.
|
||||
const [windowKey, setWindowKey] = React.useState({ open, filteredOptions })
|
||||
if (windowKey.open !== open || windowKey.filteredOptions !== filteredOptions) {
|
||||
setWindowKey({ open, filteredOptions })
|
||||
setRenderLimit(COMBOBOX_INITIAL_RENDER_LIMIT)
|
||||
setWindowStart(0)
|
||||
}
|
||||
|
||||
const renderedOptions = React.useMemo(() => {
|
||||
return filteredOptions.slice(windowStart, windowStart + renderLimit)
|
||||
}, [filteredOptions, windowStart, renderLimit])
|
||||
|
||||
// Resetting the render window alone is not enough: the listbox DOM node
|
||||
// keeps (or clamps) its previous scrollTop, so a fresh initial slice
|
||||
// would be displayed near its bottom instead of at its first match.
|
||||
React.useEffect(() => {
|
||||
if (optionsScrollRef.current) optionsScrollRef.current.scrollTop = 0
|
||||
}, [open, filteredOptions])
|
||||
|
||||
const expandRenderWindow = React.useCallback(() => {
|
||||
setRenderLimit((current) => {
|
||||
const next = comboboxNextRenderLimit(current, options.length)
|
||||
return next ?? current
|
||||
})
|
||||
}, [options.length])
|
||||
|
||||
const handleOptionsScrollCapture = React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget
|
||||
const scrollTarget = {
|
||||
clientHeight: target.clientHeight,
|
||||
scrollHeight: target.scrollHeight,
|
||||
scrollTop: target.scrollTop,
|
||||
}
|
||||
// Scrolling back to the top restores the initial window so options
|
||||
// above a slid window become reachable again. The active index must
|
||||
// be cleared too, otherwise the active-option effect immediately
|
||||
// slides the window back to a stale active option.
|
||||
// Scrolls emitted by scrolling the active option into view are not
|
||||
// manual scrolls: skipping the reset keeps ArrowUp navigation able to
|
||||
// slide into the preceding window without losing the active option.
|
||||
if (windowStart > 0 && shouldResetComboboxWindow(scrollTarget)) {
|
||||
if (navigationalScrollRef.current) return
|
||||
setActiveIndex(-1)
|
||||
setWindowStart(0)
|
||||
setRenderLimit(COMBOBOX_INITIAL_RENDER_LIMIT)
|
||||
return
|
||||
}
|
||||
// Programmatic scrolls from scrolling the active option into view can
|
||||
// also land near the bottom of a slid window (e.g. at the last window
|
||||
// while keyboard-navigating). Those are not manual scrolls either, so
|
||||
// skip expansion to keep the keyboard window at a fixed size instead
|
||||
// of growing it on every ArrowUp/ArrowDown wrap boundary.
|
||||
if (navigationalScrollRef.current) return
|
||||
if (!shouldExpandComboboxWindow(scrollTarget, renderedOptions.length, filteredOptions.length)) {
|
||||
return
|
||||
}
|
||||
// A slid window that already reaches the final option cannot mount
|
||||
// more rows by growing renderLimit (the slice is capped by the option
|
||||
// count), so growing it here without mounting anything would leave an
|
||||
// inflated limit behind; a later wrap back to index 0 would then reset
|
||||
// windowStart to zero and mount the entire grown prefix in one commit.
|
||||
if (windowStart + renderedOptions.length >= filteredOptions.length) return
|
||||
expandRenderWindow()
|
||||
}, [windowStart, renderedOptions.length, filteredOptions.length, expandRenderWindow])
|
||||
|
||||
const showCreateOption = React.useMemo(() => {
|
||||
if (!allowCreate || !inputValue.trim() || !isSearching) return false
|
||||
const lower = inputValue.toLowerCase().trim()
|
||||
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
|
||||
}, [allowCreate, inputValue, options, isSearching])
|
||||
|
||||
const selectableOptionCount = filteredOptions.length + (showCreateOption ? 1 : 0)
|
||||
const hasActiveOption = activeIndex >= 0 && activeIndex < selectableOptionCount
|
||||
|
||||
// Keyboard navigation must never land on an option that is not mounted
|
||||
// yet, otherwise its aria-activedescendant id would not exist. Slide the
|
||||
// bounded window so the destination is mounted without mounting the
|
||||
// entire prefix of a large list. This runs during render (React discards
|
||||
// the render and re-renders immediately after a render-phase state
|
||||
// update), so the commit that exposes a new active descendant already
|
||||
// has that option mounted — a post-commit effect would commit an
|
||||
// aria-activedescendant id that does not exist in the DOM yet.
|
||||
const [activeWindowKey, setActiveWindowKey] = React.useState({
|
||||
activeIndex,
|
||||
showCreateOption,
|
||||
windowStart,
|
||||
renderLimit,
|
||||
filteredCount: filteredOptions.length,
|
||||
})
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
(activeWindowKey.activeIndex !== activeIndex ||
|
||||
activeWindowKey.showCreateOption !== showCreateOption ||
|
||||
activeWindowKey.windowStart !== windowStart ||
|
||||
activeWindowKey.renderLimit !== renderLimit ||
|
||||
activeWindowKey.filteredCount !== filteredOptions.length)
|
||||
) {
|
||||
setActiveWindowKey({
|
||||
activeIndex,
|
||||
showCreateOption,
|
||||
windowStart,
|
||||
renderLimit,
|
||||
filteredCount: filteredOptions.length,
|
||||
})
|
||||
const optionIndex = activeIndex - (showCreateOption ? 1 : 0)
|
||||
const nextWindowStart = comboboxWindowStartForActiveIndex(
|
||||
optionIndex,
|
||||
windowStart,
|
||||
renderLimit,
|
||||
filteredOptions.length,
|
||||
)
|
||||
if (nextWindowStart !== null) setWindowStart(nextWindowStart)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
// Scrolling the active option into view emits scroll events even when
|
||||
// keyboard navigation slides the window toward the top of the list.
|
||||
// Mark them as navigational so the manual-scroll reset below does not
|
||||
// mistake them for the user scrolling back to the top, which would
|
||||
// clear the active option and make preceding windows unreachable by
|
||||
// ArrowUp. The flag is cleared on the next frame, once the scroll
|
||||
// events for this update have been dispatched.
|
||||
navigationalScrollRef.current = true
|
||||
activeOptionRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
requestAnimationFrame(() => {
|
||||
navigationalScrollRef.current = false
|
||||
})
|
||||
}, [activeIndex, renderedOptions.length, windowStart])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!disabled || !open) return
|
||||
setOpen(false)
|
||||
setActiveIndex(-1)
|
||||
setIsSearching(false)
|
||||
onInputValueChange?.(value ?? "")
|
||||
}, [disabled, open, onInputValueChange, value])
|
||||
|
||||
const handleSelect = (optValue: string) => {
|
||||
if (disabled) return
|
||||
onValueChange?.(optValue)
|
||||
onInputValueChange?.(optValue)
|
||||
setOpen(false)
|
||||
setActiveIndex(-1)
|
||||
const selected = options.find((opt) => opt.value === optValue)
|
||||
setInputValue(selected?.label || optValue)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (disabled) return
|
||||
const newValue = inputValue.trim()
|
||||
if (newValue) {
|
||||
onCreateNew?.(newValue)
|
||||
onValueChange?.(newValue)
|
||||
onInputValueChange?.(newValue)
|
||||
setOpen(false)
|
||||
setActiveIndex(-1)
|
||||
}
|
||||
}
|
||||
|
||||
const focusAndSelectInput = () => {
|
||||
// Defer so selection wins over click-to-place-caret on focus.
|
||||
requestAnimationFrame(() => {
|
||||
focusComboboxInput(inputRef.current, true)
|
||||
})
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
onInputValueChange?.(e.target.value)
|
||||
setIsSearching(true)
|
||||
setActiveIndex(-1)
|
||||
if (!open) setOpen(true)
|
||||
}
|
||||
|
||||
const handleInputFocus = () => {
|
||||
if (selectValueOnFocus) focusAndSelectInput()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!canComboboxOpen(disabled, nextOpen)) return
|
||||
setOpen(nextOpen)
|
||||
setActiveIndex(-1)
|
||||
if (nextOpen) {
|
||||
if (selectValueOnFocus) {
|
||||
// Opening a closed picker from its chevron should also replace on first keystroke.
|
||||
focusAndSelectInput()
|
||||
}
|
||||
} else {
|
||||
onInputValueChange?.(value ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!open) setOpen(true)
|
||||
const direction = e.key === 'ArrowDown' ? 1 : -1
|
||||
setActiveIndex((current) =>
|
||||
getNextComboboxActiveIndex(current, selectableOptionCount, direction)
|
||||
)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (hasActiveOption) {
|
||||
if (showCreateOption && activeIndex === 0) {
|
||||
handleCreate()
|
||||
} else {
|
||||
const optionIndex = activeIndex - (showCreateOption ? 1 : 0)
|
||||
const activeOption = filteredOptions[optionIndex]
|
||||
if (activeOption) handleSelect(activeOption.value)
|
||||
}
|
||||
} else if (showCreateOption) {
|
||||
handleCreate()
|
||||
} else if (filteredOptions.length === 1) {
|
||||
handleSelect(filteredOptions[0].value)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
if (disabled) return
|
||||
e.stopPropagation()
|
||||
setInputValue("")
|
||||
onInputValueChange?.("")
|
||||
onValueChange?.("")
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open && !disabled} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<div
|
||||
aria-disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm min-w-0 overflow-hidden",
|
||||
"hover:bg-secondary/50 transition-colors",
|
||||
"focus-within:outline-none focus-within:ring-1 focus-within:ring-ring",
|
||||
disabled && "cursor-not-allowed opacity-50 hover:bg-background",
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
{icon && <span className="pl-3 shrink-0 text-muted-foreground">{icon}</span>}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open && !disabled}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
open && !disabled && hasActiveOption
|
||||
? `${listboxId}-option-${activeIndex}`
|
||||
: undefined
|
||||
}
|
||||
placeholder={placeholder}
|
||||
style={inputStyle}
|
||||
className="flex-1 min-w-0 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{clearable && !disabled && inputValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="pr-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50 pr-3 box-content" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn("app-no-drag p-0 border-border/60", className)}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
||||
>
|
||||
{/* Options List */}
|
||||
<ComboboxOptionsList id={listboxId} listbox scrollRef={optionsScrollRef} onScrollCapture={handleOptionsScrollCapture}>
|
||||
{filteredOptions.length === 0 && !showCreateOption ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Create new option */}
|
||||
{showCreateOption && (
|
||||
<button
|
||||
ref={activeIndex === 0 ? activeOptionRef : undefined}
|
||||
id={`${listboxId}-option-0`}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
aria-posinset={1}
|
||||
aria-setsize={selectableOptionCount}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm hover:bg-secondary/80 transition-colors text-left",
|
||||
activeIndex === 0 && "bg-secondary/80",
|
||||
)}
|
||||
onClick={handleCreate}
|
||||
onMouseEnter={() => setActiveIndex(0)}
|
||||
>
|
||||
<Plus size={16} className="text-primary shrink-0" />
|
||||
<span className="text-muted-foreground">{createText}</span>
|
||||
<span className="font-medium text-foreground">{inputValue}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Separator if both create and options exist */}
|
||||
{showCreateOption && filteredOptions.length > 0 && (
|
||||
<div className="h-px bg-border/60 my-1" />
|
||||
)}
|
||||
|
||||
{/* Existing options (rendered incrementally for large lists) */}
|
||||
{renderedOptions.map((option, optionIndex) => {
|
||||
const selectableIndex = optionIndex + windowStart + (showCreateOption ? 1 : 0)
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={activeIndex === selectableIndex ? activeOptionRef : undefined}
|
||||
id={`${listboxId}-option-${selectableIndex}`}
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
aria-posinset={selectableIndex + 1}
|
||||
aria-setsize={selectableOptionCount}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left",
|
||||
value === option.value
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "hover:bg-secondary/80",
|
||||
activeIndex === selectableIndex && "bg-secondary/80",
|
||||
)}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
onMouseEnter={() => setActiveIndex(selectableIndex)}
|
||||
>
|
||||
{option.icon && (
|
||||
<span className="shrink-0 text-muted-foreground">{option.icon}</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate font-medium" style={option.labelStyle}>{option.label}</div>
|
||||
{option.sublabel && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{option.sublabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{value === option.value && (
|
||||
<Check size={16} className="shrink-0 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ComboboxOptionsList>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// Multi-select Combobox for tags
|
||||
interface MultiComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
values: string[];
|
||||
onValuesChange?: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
allowCreate?: boolean;
|
||||
onCreateNew?: (value: string) => void;
|
||||
createText?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MultiCombobox({
|
||||
options,
|
||||
values,
|
||||
onValuesChange,
|
||||
placeholder = "Add...",
|
||||
emptyText = "No results found",
|
||||
allowCreate = false,
|
||||
onCreateNew,
|
||||
createText = "Create Tag",
|
||||
icon,
|
||||
className,
|
||||
triggerClassName,
|
||||
disabled = false,
|
||||
}: MultiComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!inputValue.trim()) return options
|
||||
const lower = inputValue.toLowerCase()
|
||||
return options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(lower) ||
|
||||
opt.value.toLowerCase().includes(lower)
|
||||
)
|
||||
}, [options, inputValue])
|
||||
|
||||
const showCreateOption = React.useMemo(() => {
|
||||
if (!allowCreate || !inputValue.trim()) return false
|
||||
const lower = inputValue.toLowerCase().trim()
|
||||
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
|
||||
}, [allowCreate, inputValue, options])
|
||||
|
||||
const handleToggle = (optValue: string) => {
|
||||
const newValues = values.includes(optValue)
|
||||
? values.filter((v) => v !== optValue)
|
||||
: [...values, optValue]
|
||||
onValuesChange?.(newValues)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
const newValue = inputValue.trim()
|
||||
if (newValue && !values.includes(newValue)) {
|
||||
onCreateNew?.(newValue)
|
||||
onValuesChange?.([...values, newValue])
|
||||
setInputValue("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (e: React.MouseEvent, val: string) => {
|
||||
e.stopPropagation()
|
||||
onValuesChange?.(values.filter((v) => v !== val))
|
||||
}
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (showCreateOption) {
|
||||
handleCreate()
|
||||
} else if (filteredOptions.length === 1 && !values.includes(filteredOptions[0].value)) {
|
||||
handleToggle(filteredOptions[0].value)
|
||||
setInputValue("")
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
} else if (e.key === 'Backspace' && !inputValue && values.length > 0) {
|
||||
// Remove last tag on backspace when input is empty
|
||||
onValuesChange?.(values.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-10 w-full items-center gap-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm",
|
||||
"hover:bg-secondary/50 transition-colors cursor-text",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
triggerClassName
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{icon && <span className="pl-1 shrink-0 text-muted-foreground">{icon}</span>}
|
||||
<div className="flex-1 flex flex-wrap gap-1.5 items-center min-w-0">
|
||||
{values.map((val) => (
|
||||
<span
|
||||
key={val}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-primary/10 text-primary text-xs font-medium"
|
||||
>
|
||||
{val}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRemove(e, val)}
|
||||
className="hover:bg-primary/20 rounded p-0.5"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={values.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[60px] h-6 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn("app-no-drag p-0 border-border/60", className)}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
||||
>
|
||||
{/* Options List */}
|
||||
<ComboboxOptionsList>
|
||||
{filteredOptions.length === 0 && !showCreateOption ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Create new option */}
|
||||
{showCreateOption && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Plus size={16} className="text-primary shrink-0" />
|
||||
<span className="text-muted-foreground">{createText}</span>
|
||||
<span className="font-medium text-foreground">{inputValue}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Separator if both create and options exist */}
|
||||
{showCreateOption && filteredOptions.length > 0 && (
|
||||
<div className="h-px bg-border/60 my-1" />
|
||||
)}
|
||||
|
||||
{/* Existing options */}
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = values.includes(option.value)
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left",
|
||||
isSelected
|
||||
? "bg-primary/10 text-foreground"
|
||||
: "hover:bg-secondary/80"
|
||||
)}
|
||||
onClick={() => {
|
||||
handleToggle(option.value)
|
||||
setInputValue("")
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
|
||||
isSelected ? "bg-primary border-primary" : "border-muted-foreground/40"
|
||||
)}>
|
||||
{isSelected && <Check size={12} className="text-primary-foreground" />}
|
||||
</div>
|
||||
<span className="truncate flex-1">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ComboboxOptionsList>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default Combobox
|
||||
68
components/ui/confirm-dialog.tsx
Normal file
68
components/ui/confirm-dialog.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./dialog";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel: string;
|
||||
busy?: boolean;
|
||||
destructive?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmDialog = memo(function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
busy = false,
|
||||
destructive = false,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
|
||||
<DialogHeader className="min-w-0 pr-6">
|
||||
<DialogTitle className="truncate">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{message ? (
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words text-sm text-muted-foreground [overflow-wrap:anywhere]">{message}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
272
components/ui/context-menu.tsx
Normal file
272
components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const menuCollisionPadding = { top: 36, bottom: 12, left: 12, right: 12 };
|
||||
const CONTEXT_MENU_PORTAL_ID = "netcatty-context-menu-root";
|
||||
|
||||
// Dedicated portal root so context menus always sit above every other layer (dialogs, titlebar, overlays)
|
||||
const getContextMenuPortalEl = () => {
|
||||
if (typeof document === "undefined") return null;
|
||||
let portal = document.getElementById(CONTEXT_MENU_PORTAL_ID);
|
||||
if (!portal) {
|
||||
portal = document.createElement("div");
|
||||
portal.id = CONTEXT_MENU_PORTAL_ID;
|
||||
Object.assign(portal.style, {
|
||||
position: "fixed",
|
||||
inset: "0px",
|
||||
zIndex: "2147483647", // max safe z-index to avoid being covered
|
||||
pointerEvents: "none",
|
||||
});
|
||||
|
||||
// Intercept aria-hidden attribute to prevent it from being set when menu is open
|
||||
// This avoids "Blocked aria-hidden on an element because its descendant retained focus" warnings
|
||||
let ariaHiddenValue: string | null = null;
|
||||
Object.defineProperty(portal, "ariaHidden", {
|
||||
get() {
|
||||
return ariaHiddenValue;
|
||||
},
|
||||
set(value: string | null) {
|
||||
// Block aria-hidden="true" when there are children (menu is open)
|
||||
if (value === "true" && portal && portal.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
ariaHiddenValue = value;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Also override setAttribute for aria-hidden
|
||||
const originalSetAttribute = portal.setAttribute.bind(portal);
|
||||
portal.setAttribute = function (name: string, value: string) {
|
||||
if (name === "aria-hidden" && value === "true" && portal && portal.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
originalSetAttribute(name, value);
|
||||
};
|
||||
|
||||
// Override removeAttribute to sync our internal state
|
||||
const originalRemoveAttribute = portal.removeAttribute.bind(portal);
|
||||
portal.removeAttribute = function (name: string) {
|
||||
if (name === "aria-hidden") {
|
||||
ariaHiddenValue = null;
|
||||
}
|
||||
originalRemoveAttribute(name);
|
||||
};
|
||||
|
||||
document.body.appendChild(portal);
|
||||
}
|
||||
return portal;
|
||||
};
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const portalContainer = React.useMemo(
|
||||
() => getContextMenuPortalEl() ?? undefined,
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal container={portalContainer}>
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
sideOffset={6}
|
||||
collisionPadding={menuCollisionPadding}
|
||||
className={cn(
|
||||
"z-[200000] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg app-no-drag pointer-events-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const portalContainer = React.useMemo(
|
||||
() => getContextMenuPortalEl() ?? undefined,
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal container={portalContainer}>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
collisionPadding={menuCollisionPadding}
|
||||
className={cn(
|
||||
"z-[200000] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 app-no-drag pointer-events-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center whitespace-nowrap rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 pl-4 text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
};
|
||||
131
components/ui/dialog.tsx
Normal file
131
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { useI18n } from "../../application/i18n/I18nProvider"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean; overlayClassName?: string }
|
||||
>(({ className, children, hideCloseButton, overlayClassName, ...props }, ref) => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
style={{ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 12px 24px -8px rgba(0, 0, 0, 0.15)' }}
|
||||
aria-describedby={undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
data-dialog-close="true"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{t("common.close")}
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-dialog-close="true"
|
||||
className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
})
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-snug tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger
|
||||
}
|
||||
310
components/ui/dropdown.tsx
Normal file
310
components/ui/dropdown.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface DropdownContextValue {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
}
|
||||
|
||||
const DropdownContext = React.createContext<DropdownContextValue | null>(null);
|
||||
|
||||
function useDropdown() {
|
||||
const context = React.useContext(DropdownContext);
|
||||
if (!context) {
|
||||
throw new Error("Dropdown components must be used within a Dropdown");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const setOpen = useCallback(
|
||||
(value: boolean) => {
|
||||
if (controlledOpen === undefined) {
|
||||
setInternalOpen(value);
|
||||
}
|
||||
onOpenChange?.(value);
|
||||
},
|
||||
[controlledOpen, onOpenChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const closeOnPageHidden = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", closeOnPageHidden);
|
||||
return () => document.removeEventListener("visibilitychange", closeOnPageHidden);
|
||||
}, [setOpen]);
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider value={{ open, setOpen, triggerRef }}>
|
||||
{children}
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownTriggerProps {
|
||||
children: React.ReactElement;
|
||||
asChild?: boolean;
|
||||
toggleOnClick?: boolean;
|
||||
}
|
||||
|
||||
const DropdownTrigger: React.FC<DropdownTriggerProps> = ({
|
||||
children,
|
||||
asChild,
|
||||
toggleOnClick = true,
|
||||
}) => {
|
||||
const { open, setOpen, triggerRef } = useDropdown();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(
|
||||
children as React.ReactElement<{
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}>,
|
||||
{
|
||||
ref: triggerRef,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
const childProps = children.props as {
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
childProps?.onClick?.(e);
|
||||
if (toggleOnClick && !e.defaultPrevented) {
|
||||
handleClick(e);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button ref={triggerRef} onClick={handleClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface DropdownContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
sideOffset?: number;
|
||||
side?: "top" | "bottom";
|
||||
/** If true, align to the trigger's parent element instead of the trigger itself */
|
||||
alignToParent?: boolean;
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const DropdownContent: React.FC<DropdownContentProps> = ({
|
||||
children,
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
side = "bottom",
|
||||
alignToParent = false,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
const { open, setOpen, triggerRef } = useDropdown();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
maxHeight?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Calculate position function
|
||||
const calculatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return null;
|
||||
|
||||
// Use parent element if alignToParent is true
|
||||
const anchorEl = alignToParent
|
||||
? triggerRef.current.parentElement
|
||||
: triggerRef.current;
|
||||
if (!anchorEl) return null;
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const contentEl = contentRef.current;
|
||||
|
||||
const contentHeight = contentEl?.offsetHeight ?? 0;
|
||||
const contentWidth = contentEl?.offsetWidth ?? 0;
|
||||
const viewportMargin = 8;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = Math.max(
|
||||
0,
|
||||
viewportHeight - triggerRect.bottom - sideOffset - viewportMargin,
|
||||
);
|
||||
const spaceAbove = Math.max(
|
||||
0,
|
||||
triggerRect.top - sideOffset - viewportMargin,
|
||||
);
|
||||
|
||||
// Prefer requested side when it fits; otherwise pick the side with more room.
|
||||
// Never bounce oversized menus off-screen by flipping twice.
|
||||
let placeBelow: boolean;
|
||||
if (side === "bottom") {
|
||||
if (contentHeight <= spaceBelow || spaceBelow >= spaceAbove) {
|
||||
placeBelow = true;
|
||||
} else {
|
||||
placeBelow = false;
|
||||
}
|
||||
} else if (contentHeight <= spaceAbove || spaceAbove > spaceBelow) {
|
||||
placeBelow = false;
|
||||
} else {
|
||||
placeBelow = true;
|
||||
}
|
||||
|
||||
const available = placeBelow ? spaceBelow : spaceAbove;
|
||||
const maxHeight =
|
||||
contentHeight > available && available > 0
|
||||
? available
|
||||
: undefined;
|
||||
const effectiveHeight = maxHeight ?? contentHeight;
|
||||
|
||||
let top: number;
|
||||
if (placeBelow) {
|
||||
top = triggerRect.bottom + sideOffset;
|
||||
} else {
|
||||
top = triggerRect.top - effectiveHeight - sideOffset;
|
||||
if (top < viewportMargin) {
|
||||
top = viewportMargin;
|
||||
}
|
||||
}
|
||||
|
||||
// Use anchor element (parent or trigger) for horizontal positioning
|
||||
let left: number;
|
||||
if (align === "start") {
|
||||
left = rect.left;
|
||||
} else if (align === "end") {
|
||||
left = contentEl ? rect.right - contentWidth : rect.right;
|
||||
} else {
|
||||
left = rect.left + rect.width / 2;
|
||||
if (contentEl) {
|
||||
left -= contentWidth / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentEl) {
|
||||
if (left + contentWidth > viewportWidth - viewportMargin) {
|
||||
left = viewportWidth - contentWidth - viewportMargin;
|
||||
}
|
||||
if (left < viewportMargin) {
|
||||
left = viewportMargin;
|
||||
}
|
||||
}
|
||||
|
||||
return { top, left, maxHeight };
|
||||
}, [align, sideOffset, side, alignToParent, triggerRef]);
|
||||
|
||||
// Calculate position synchronously after DOM updates
|
||||
useLayoutEffect(() => {
|
||||
if (open) {
|
||||
// Reset position first to hide content while calculating
|
||||
setPosition(null);
|
||||
|
||||
// Use double requestAnimationFrame to ensure content is fully rendered
|
||||
// First frame: content is added to DOM
|
||||
// Second frame: layout is calculated, offsetWidth is available
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const pos = calculatePosition();
|
||||
if (pos) setPosition(pos);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setPosition(null);
|
||||
}
|
||||
}, [open, calculatePosition]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
contentRef.current &&
|
||||
!contentRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Use setTimeout to avoid closing immediately on the same click that opened it
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [open, setOpen, triggerRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"fixed z-[999999] rounded-md border border-border/60 bg-popover p-1 text-popover-foreground shadow-md",
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
top: position?.top ?? -9999,
|
||||
left: position?.left ?? -9999,
|
||||
maxHeight: position?.maxHeight,
|
||||
overflowY: position?.maxHeight ? "auto" : undefined,
|
||||
visibility: position ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export { Dropdown, DropdownContent, DropdownTrigger };
|
||||
30
components/ui/hover-card.tsx
Normal file
30
components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-[999999] rounded-md border border-border/60 bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
90
components/ui/input-group.tsx
Normal file
90
components/ui/input-group.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export type InputGroupProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col rounded-xl border border-border/65 bg-background transition-[border-color,background-color]',
|
||||
'focus-within:border-primary/45 focus-within:ring-1 focus-within:ring-primary/20',
|
||||
'overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroup.displayName = 'InputGroup';
|
||||
|
||||
export type InputGroupTextareaProps = ComponentProps<'textarea'>;
|
||||
|
||||
export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, InputGroupTextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full resize-none bg-transparent text-[13px] text-foreground/92 selection:bg-primary/25',
|
||||
'placeholder:text-muted-foreground/62 placeholder:font-medium placeholder:text-[13px]',
|
||||
'focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed',
|
||||
'px-4 pt-3.5 pb-2 leading-[20px]',
|
||||
'field-sizing-content min-h-[82px] max-h-52',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupTextarea.displayName = 'InputGroupTextarea';
|
||||
|
||||
export type InputGroupAddonProps = HTMLAttributes<HTMLDivElement> & {
|
||||
align?: 'block-start' | 'block-end';
|
||||
};
|
||||
|
||||
export const InputGroupAddon = forwardRef<HTMLDivElement, InputGroupAddonProps>(
|
||||
({ className, align = 'block-end', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center px-2.5 py-1.5',
|
||||
align === 'block-start' && 'border-b border-border/35 bg-muted/8',
|
||||
align === 'block-end' && 'border-t border-border/60 bg-muted/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupAddon.displayName = 'InputGroupAddon';
|
||||
|
||||
export type InputGroupButtonProps = ComponentProps<'button'> & {
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'destructive';
|
||||
size?: 'sm' | 'icon-sm' | 'default';
|
||||
};
|
||||
|
||||
export const InputGroupButton = forwardRef<HTMLButtonElement, InputGroupButtonProps>(
|
||||
({ className, variant = 'ghost', size = 'icon-sm', disabled, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md transition-colors cursor-pointer',
|
||||
'disabled:opacity-30 disabled:cursor-default',
|
||||
size === 'icon-sm' && 'h-7 w-7',
|
||||
size === 'sm' && 'h-7 px-2 text-[12px] gap-1',
|
||||
size === 'default' && 'h-8 px-3 text-[13px] gap-1.5',
|
||||
variant === 'ghost' && 'text-muted-foreground/78 hover:text-foreground hover:bg-muted/45',
|
||||
variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary',
|
||||
variant === 'outline' && 'border border-border/40 text-muted-foreground/85 hover:text-foreground hover:bg-muted/35',
|
||||
variant === 'destructive' && 'text-destructive/70 hover:text-destructive hover:bg-destructive/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
InputGroupButton.displayName = 'InputGroupButton';
|
||||
24
components/ui/input.tsx
Normal file
24
components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> { }
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
19
components/ui/label.tsx
Normal file
19
components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-5 peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
72
components/ui/lazy-load-boundary.tsx
Normal file
72
components/ui/lazy-load-boundary.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { Component } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type LazyLoadBoundaryProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
fallback?: React.ReactNode | ((error: Error) => React.ReactNode);
|
||||
name?: string;
|
||||
resetKey?: React.Key | null;
|
||||
};
|
||||
|
||||
type LazyLoadBoundaryState = {
|
||||
error: Error | null;
|
||||
retryKey: number;
|
||||
};
|
||||
|
||||
export class LazyLoadBoundary extends Component<LazyLoadBoundaryProps, LazyLoadBoundaryState> {
|
||||
declare props: Readonly<LazyLoadBoundaryProps>;
|
||||
declare setState: React.Component<LazyLoadBoundaryProps, LazyLoadBoundaryState>["setState"];
|
||||
state: LazyLoadBoundaryState = { error: null, retryKey: 0 };
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<LazyLoadBoundaryState> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: LazyLoadBoundaryProps) {
|
||||
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
private retry = () => {
|
||||
if (typeof window !== "undefined" && typeof window.location?.reload === "function") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
this.setState(({ retryKey }) => ({ error: null, retryKey: retryKey + 1 }));
|
||||
};
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error(`[LazyLoadBoundary] ${this.props.name || "content"} failed:`, error, errorInfo.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
const { fallback } = this.props;
|
||||
if (typeof fallback === "function") return fallback(this.state.error);
|
||||
if (fallback) return fallback;
|
||||
const label = this.props.name || "This area";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground",
|
||||
this.props.className,
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<div className="font-medium text-foreground">{label} could not load.</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
onClick={this.retry}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment key={this.state.retryKey}>{this.props.children}</React.Fragment>;
|
||||
}
|
||||
}
|
||||
75
components/ui/popover.tsx
Normal file
75
components/ui/popover.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as React from "react"
|
||||
import { useCallback, useLayoutEffect, useState } from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
import { usePortalContainer } from "./portal-container"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverClose = PopoverPrimitive.Close
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||
const portalContainer = usePortalContainer()
|
||||
// Fix for Electron: ensure position is calculated after content is fully rendered
|
||||
const [isPositioned, setIsPositioned] = useState(false)
|
||||
const [node, setNode] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
// Use callback ref to detect when element is mounted
|
||||
const callbackRef = useCallback((element: HTMLDivElement | null) => {
|
||||
setNode(element)
|
||||
// Forward ref
|
||||
if (typeof ref === 'function') {
|
||||
ref(element)
|
||||
} else if (ref) {
|
||||
ref.current = element
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!node) {
|
||||
setIsPositioned(false)
|
||||
return
|
||||
}
|
||||
// Element just mounted, wait for next frame to ensure position is calculated
|
||||
setIsPositioned(false)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsPositioned(true)
|
||||
})
|
||||
})
|
||||
}, [node])
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={callbackRef}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
// Force position recalculation on every animation frame
|
||||
updatePositionStrategy="always"
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
style={{
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
className={cn(
|
||||
"z-[999999] rounded-md border border-border/60 bg-popover p-4 text-popover-foreground shadow-md outline-none pointer-events-auto",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
})
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger }
|
||||
29
components/ui/portal-container.test.tsx
Normal file
29
components/ui/portal-container.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { PortalContainerProvider, usePortalContainer } from './portal-container';
|
||||
|
||||
test('portal container is available to nested editor controls', () => {
|
||||
const marker = {} as HTMLElement;
|
||||
const Probe = () => <span>{usePortalContainer() === marker ? 'inside' : 'outside'}</span>;
|
||||
|
||||
assert.equal(
|
||||
renderToStaticMarkup(
|
||||
<PortalContainerProvider container={marker}>
|
||||
<Probe />
|
||||
</PortalContainerProvider>,
|
||||
),
|
||||
'<span>inside</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('editor dropdown primitives use the scoped portal container', () => {
|
||||
for (const file of ['popover.tsx', 'select.tsx', 'tooltip.tsx']) {
|
||||
const source = readFileSync(new URL(`./${file}`, import.meta.url), 'utf8');
|
||||
assert.match(source, /usePortalContainer\(\)/);
|
||||
assert.match(source, /Portal container=\{portalContainer \?\? undefined\}/);
|
||||
}
|
||||
});
|
||||
14
components/ui/portal-container.tsx
Normal file
14
components/ui/portal-container.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
const PortalContainerContext = createContext<HTMLElement | null>(null);
|
||||
|
||||
export const PortalContainerProvider: React.FC<{
|
||||
container: HTMLElement | null;
|
||||
children: React.ReactNode;
|
||||
}> = ({ container, children }) => (
|
||||
<PortalContainerContext.Provider value={container}>
|
||||
{children}
|
||||
</PortalContainerContext.Provider>
|
||||
);
|
||||
|
||||
export const usePortalContainer = (): HTMLElement | null => useContext(PortalContainerContext);
|
||||
97
components/ui/primaryOnlyDrag.test.ts
Normal file
97
components/ui/primaryOnlyDrag.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
primaryOnlyDragHandlers,
|
||||
restorePrimaryOnlyDrag,
|
||||
} from "./primaryOnlyDrag.ts";
|
||||
|
||||
const fakeRoot = () => {
|
||||
const listeners = new Map<string, Set<EventListener>>();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type: string, listener: EventListener) {
|
||||
const set = listeners.get(type) ?? new Set();
|
||||
set.add(listener);
|
||||
listeners.set(type, set);
|
||||
},
|
||||
removeEventListener(type: string, listener: EventListener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
dispatch(type: string) {
|
||||
for (const listener of Array.from(listeners.get(type) ?? [])) {
|
||||
listener(new Event(type));
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test("primaryOnlyDragHandlers restores draggable after a right-click is released outside", () => {
|
||||
const root = fakeRoot();
|
||||
const target = { draggable: true } as HTMLElement;
|
||||
const handlers = primaryOnlyDragHandlers(true, root);
|
||||
|
||||
handlers.onPointerDown({
|
||||
button: 2,
|
||||
currentTarget: target,
|
||||
} as Parameters<typeof handlers.onPointerDown>[0]);
|
||||
|
||||
assert.equal(target.draggable, false);
|
||||
assert.equal(root.listeners.get("pointerup")?.size, 1);
|
||||
assert.equal(root.listeners.get("pointercancel")?.size, 1);
|
||||
|
||||
root.dispatch("pointerup");
|
||||
|
||||
assert.equal(target.draggable, true);
|
||||
assert.equal(root.listeners.get("pointerup")?.size, 0);
|
||||
assert.equal(root.listeners.get("pointercancel")?.size, 0);
|
||||
});
|
||||
|
||||
test("primaryOnlyDragHandlers pointercancel on the document also restores draggable", () => {
|
||||
const root = fakeRoot();
|
||||
const target = { draggable: true } as HTMLElement;
|
||||
const handlers = primaryOnlyDragHandlers(true, root);
|
||||
|
||||
handlers.onPointerDown({
|
||||
button: 2,
|
||||
currentTarget: target,
|
||||
} as Parameters<typeof handlers.onPointerDown>[0]);
|
||||
root.dispatch("pointercancel");
|
||||
|
||||
assert.equal(target.draggable, true);
|
||||
assert.equal(root.listeners.get("pointerup")?.size, 0);
|
||||
assert.equal(root.listeners.get("pointercancel")?.size, 0);
|
||||
});
|
||||
|
||||
test("element-level pointerup clears the document restore listeners", () => {
|
||||
const root = fakeRoot();
|
||||
const target = { draggable: true } as HTMLElement;
|
||||
const handlers = primaryOnlyDragHandlers(true, root);
|
||||
|
||||
handlers.onPointerDown({
|
||||
button: 2,
|
||||
currentTarget: target,
|
||||
} as Parameters<typeof handlers.onPointerDown>[0]);
|
||||
handlers.onPointerUp({
|
||||
currentTarget: target,
|
||||
} as Parameters<typeof handlers.onPointerUp>[0]);
|
||||
|
||||
assert.equal(target.draggable, true);
|
||||
assert.equal(root.listeners.get("pointerup")?.size, 0);
|
||||
restorePrimaryOnlyDrag(target, false);
|
||||
assert.equal(target.draggable, false);
|
||||
});
|
||||
|
||||
test("primary pointer down does not disable dragging", () => {
|
||||
const root = fakeRoot();
|
||||
const target = { draggable: true } as HTMLElement;
|
||||
const handlers = primaryOnlyDragHandlers(true, root);
|
||||
|
||||
handlers.onPointerDown({
|
||||
button: 0,
|
||||
currentTarget: target,
|
||||
} as Parameters<typeof handlers.onPointerDown>[0]);
|
||||
|
||||
assert.equal(target.draggable, true);
|
||||
assert.equal(root.listeners.get("pointerup")?.size ?? 0, 0);
|
||||
});
|
||||
58
components/ui/primaryOnlyDrag.ts
Normal file
58
components/ui/primaryOnlyDrag.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
export type PrimaryOnlyDragEventRoot = Pick<
|
||||
EventTarget,
|
||||
"addEventListener" | "removeEventListener"
|
||||
>;
|
||||
|
||||
export const isNonPrimaryPointer = (event: { button?: number; buttons?: number }): boolean =>
|
||||
event.button === 2
|
||||
|| event.buttons === 2
|
||||
|| (typeof event.button === "number" && event.button !== 0);
|
||||
|
||||
const pendingRestore = new WeakMap<HTMLElement, () => void>();
|
||||
|
||||
export function restorePrimaryOnlyDrag(target: HTMLElement, enabled: boolean): void {
|
||||
const restore = pendingRestore.get(target);
|
||||
if (restore) {
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
target.draggable = enabled;
|
||||
}
|
||||
|
||||
export function armPrimaryOnlyDragRestore(
|
||||
target: HTMLElement,
|
||||
enabled: boolean,
|
||||
root: PrimaryOnlyDragEventRoot = window,
|
||||
): void {
|
||||
pendingRestore.get(target)?.();
|
||||
const restore = () => {
|
||||
target.draggable = enabled;
|
||||
root.removeEventListener("pointerup", restore, true);
|
||||
root.removeEventListener("pointercancel", restore, true);
|
||||
if (pendingRestore.get(target) === restore) pendingRestore.delete(target);
|
||||
};
|
||||
pendingRestore.set(target, restore);
|
||||
root.addEventListener("pointerup", restore, true);
|
||||
root.addEventListener("pointercancel", restore, true);
|
||||
}
|
||||
|
||||
export function primaryOnlyDragHandlers(
|
||||
enabled: boolean,
|
||||
root?: PrimaryOnlyDragEventRoot,
|
||||
) {
|
||||
return {
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.button === 0) return;
|
||||
event.currentTarget.draggable = false;
|
||||
armPrimaryOnlyDragRestore(event.currentTarget, enabled, root ?? window);
|
||||
},
|
||||
onPointerUp: (event: ReactPointerEvent<HTMLElement>) => {
|
||||
restorePrimaryOnlyDrag(event.currentTarget, enabled);
|
||||
},
|
||||
onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => {
|
||||
restorePrimaryOnlyDrag(event.currentTarget, enabled);
|
||||
},
|
||||
};
|
||||
}
|
||||
63
components/ui/ripple.tsx
Normal file
63
components/ui/ripple.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button, ButtonProps } from "./button";
|
||||
|
||||
interface RippleState {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const RIPPLE_DURATION_MS = 600;
|
||||
|
||||
export const RippleButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ children, className, onPointerDown, ...props }, ref) => {
|
||||
const [ripples, setRipples] = React.useState<RippleState[]>([]);
|
||||
const nextId = React.useRef(0);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height) * 2;
|
||||
const x = e.clientX - rect.left - size / 2;
|
||||
const y = e.clientY - rect.top - size / 2;
|
||||
const id = nextId.current++;
|
||||
setRipples((rs) => [...rs, { id, x, y, size }]);
|
||||
window.setTimeout(
|
||||
() => setRipples((rs) => rs.filter((r) => r.id !== id)),
|
||||
RIPPLE_DURATION_MS,
|
||||
);
|
||||
onPointerDown?.(e);
|
||||
},
|
||||
[onPointerDown],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
onPointerDown={handlePointerDown}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="pointer-events-none absolute inset-0">
|
||||
{ripples.map((r) => (
|
||||
<span
|
||||
key={r.id}
|
||||
className="absolute rounded-full bg-current"
|
||||
style={{
|
||||
left: r.x,
|
||||
top: r.y,
|
||||
width: r.size,
|
||||
height: r.size,
|
||||
animation: `ripple ${RIPPLE_DURATION_MS}ms ease-out forwards`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
RippleButton.displayName = "RippleButton";
|
||||
46
components/ui/scroll-area.tsx
Normal file
46
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full max-h-[inherit] rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea,ScrollBar }
|
||||
164
components/ui/select.tsx
Normal file
164
components/ui/select.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
import { usePortalContainer } from "./portal-container"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const fitSelectedText = typeof className === "string" && !className.includes("w-full");
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full max-w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:min-w-0 [&>span]:truncate [&>span]:whitespace-nowrap",
|
||||
fitSelectedText && "min-w-max",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
})
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
type SelectContentProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||
hideScrollButtons?: boolean;
|
||||
}
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
SelectContentProps
|
||||
>(({ className, children, position = "popper", hideScrollButtons = false, ...props }, ref) => {
|
||||
const portalContainer = usePortalContainer()
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Portal container={portalContainer ?? undefined}>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
// Disable animations - they cause stacking/positioning issues on first open
|
||||
className={cn(
|
||||
"z-[200000] max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{!hideScrollButtons && <SelectScrollUpButton />}
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
{!hideScrollButtons && <SelectScrollDownButton />}
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
})
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue
|
||||
}
|
||||
58
components/ui/sort-dropdown.tsx
Normal file
58
components/ui/sort-dropdown.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,GripVertical,SortAsc,SortDesc } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Button } from './button';
|
||||
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
|
||||
|
||||
export type SortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
|
||||
const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElement; triggerIcon: React.ReactElement }> = {
|
||||
manual: { labelKey: 'sort.manual', icon: <GripVertical className="w-4 h-4 shrink-0" />, triggerIcon: <GripVertical className="w-4 h-4" /> },
|
||||
az: { labelKey: 'sort.az', icon: <SortAsc className="w-4 h-4 shrink-0" />, triggerIcon: <SortAsc className="w-4 h-4" /> },
|
||||
za: { labelKey: 'sort.za', icon: <SortDesc className="w-4 h-4 shrink-0" />, triggerIcon: <SortDesc className="w-4 h-4" /> },
|
||||
newest: { labelKey: 'sort.newest', icon: <Calendar className="w-4 h-4 shrink-0" />, triggerIcon: <Calendar className="w-4 h-4" /> },
|
||||
oldest: { labelKey: 'sort.oldest', icon: <CalendarClock className="w-4 h-4 shrink-0" />, triggerIcon: <CalendarClock className="w-4 h-4" /> },
|
||||
group: { labelKey: 'sort.group', icon: <FolderTree className="w-4 h-4 shrink-0" />, triggerIcon: <FolderTree className="w-4 h-4" /> },
|
||||
};
|
||||
|
||||
interface SortDropdownProps {
|
||||
value: SortMode;
|
||||
onChange: (mode: SortMode) => void;
|
||||
className?: string;
|
||||
modes?: SortMode[];
|
||||
}
|
||||
|
||||
export const SortDropdown: React.FC<SortDropdownProps> = ({ value, onChange, className, modes }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { t } = useI18n();
|
||||
const visibleModes = modes ?? (Object.keys(SORT_OPTIONS) as SortMode[]);
|
||||
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className={className || "h-8 w-8"}>
|
||||
{SORT_OPTIONS[value].triggerIcon}
|
||||
{open ? <ChevronUp size={10} className="ml-0.5" /> : <ChevronDown size={10} className="ml-0.5" />}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-44" align="end">
|
||||
{visibleModes.map(mode => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={value === mode ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => {
|
||||
onChange(mode);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{SORT_OPTIONS[mode].icon} {t(SORT_OPTIONS[mode].labelKey)}
|
||||
{value === mode && <Check size={12} className="ml-auto" />}
|
||||
</Button>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortDropdown;
|
||||
9
components/ui/spinner.tsx
Normal file
9
components/ui/spinner.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
export type SpinnerProps = ComponentProps<typeof Loader2>;
|
||||
|
||||
export const Spinner = ({ className, size = 16, ...props }: SpinnerProps) => (
|
||||
<Loader2 className={cn('animate-spin', className)} size={size} {...props} />
|
||||
);
|
||||
76
components/ui/switch.tsx
Normal file
76
components/ui/switch.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface SwitchProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
|
||||
const {
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
id,
|
||||
...inputProps
|
||||
} = props;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled && onCheckedChange) {
|
||||
onCheckedChange(!checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!disabled && onCheckedChange) {
|
||||
onCheckedChange(!checked);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="switch"
|
||||
id={id}
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-primary" : "bg-input",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
{...inputProps}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Switch.displayName = "Switch"
|
||||
|
||||
export { Switch }
|
||||
53
components/ui/tabs.tsx
Normal file
53
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 focus-visible:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger }
|
||||
253
components/ui/tag-filter-dropdown.tsx
Normal file
253
components/ui/tag-filter-dropdown.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Check,ChevronDown,ChevronUp,Pencil,Search,Tag,Trash2,X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
|
||||
import { Input } from './input';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
|
||||
interface TagFilterDropdownProps {
|
||||
allTags: string[];
|
||||
selectedTags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
onEditTag?: (oldTag: string, newTag: string) => void;
|
||||
onDeleteTag?: (tag: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TagFilterDropdown: React.FC<TagFilterDropdownProps> = ({
|
||||
allTags,
|
||||
selectedTags,
|
||||
onChange,
|
||||
onEditTag,
|
||||
onDeleteTag,
|
||||
className,
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [editingTag, setEditingTag] = React.useState<string | null>(null);
|
||||
const [editValue, setEditValue] = React.useState('');
|
||||
const editInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
if (selectedTags.includes(tag)) {
|
||||
onChange(selectedTags.filter(t => t !== tag));
|
||||
} else {
|
||||
onChange([...selectedTags, tag]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
const hasFilters = selectedTags.length > 0;
|
||||
|
||||
// Filter tags based on search query
|
||||
const filteredTags = React.useMemo(() => {
|
||||
if (!searchQuery.trim()) return allTags;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allTags.filter(tag => tag.toLowerCase().includes(query));
|
||||
}, [allTags, searchQuery]);
|
||||
|
||||
// Start editing a tag
|
||||
const startEditing = (tag: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditingTag(tag);
|
||||
setEditValue(tag);
|
||||
setTimeout(() => editInputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// Save edited tag
|
||||
const saveEdit = () => {
|
||||
if (editingTag && editValue.trim() && editValue !== editingTag && onEditTag) {
|
||||
onEditTag(editingTag, editValue.trim());
|
||||
// Update selected tags if the edited tag was selected
|
||||
if (selectedTags.includes(editingTag)) {
|
||||
onChange(selectedTags.map(t => t === editingTag ? editValue.trim() : t));
|
||||
}
|
||||
}
|
||||
setEditingTag(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
// Cancel editing
|
||||
const cancelEdit = () => {
|
||||
setEditingTag(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
// Handle edit input key events
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a tag
|
||||
const handleDelete = (tag: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onDeleteTag) {
|
||||
onDeleteTag(tag);
|
||||
// Remove from selected tags if it was selected
|
||||
if (selectedTags.includes(tag)) {
|
||||
onChange(selectedTags.filter(t => t !== tag));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Reset state when popover closes
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('');
|
||||
setEditingTag(null);
|
||||
setEditValue('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const canEdit = !!onEditTag;
|
||||
const canDelete = !!onDeleteTag;
|
||||
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
className || "h-8 w-8",
|
||||
hasFilters && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Tag size={14} />
|
||||
{open ? <ChevronUp size={10} className="ml-0.5" /> : <ChevronDown size={10} className="ml-0.5" />}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-64" align="end">
|
||||
{allTags.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No tags available
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search input */}
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tags"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-7 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 h-8 text-muted-foreground"
|
||||
onClick={clearAll}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border my-1" />
|
||||
|
||||
<ScrollArea className="max-h-[240px]">
|
||||
<div className="space-y-0.5">
|
||||
{filteredTags.length === 0 ? (
|
||||
<div className="px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
No matching tags
|
||||
</div>
|
||||
) : (
|
||||
filteredTags.map(tag => {
|
||||
const isSelected = selectedTags.includes(tag);
|
||||
const isEditing = editingTag === tag;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div key={tag} className="flex items-center gap-1 px-2 py-1">
|
||||
<Input
|
||||
ref={editInputRef}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={saveEdit}
|
||||
className="h-7 text-sm flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tag}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-8 px-2 rounded-md cursor-pointer group",
|
||||
isSelected ? "bg-secondary" : "hover:bg-muted/60"
|
||||
)}
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-3 w-3 rounded-full border shrink-0",
|
||||
isSelected ? "bg-primary border-primary" : "border-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate flex-1 text-sm">{tag}</span>
|
||||
{isSelected && <Check size={12} className="shrink-0 text-primary" />}
|
||||
|
||||
{/* Edit & Delete buttons - show on hover when handlers provided */}
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => startEditing(tag, e)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDelete(tag, e)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagFilterDropdown;
|
||||
23
components/ui/textarea.tsx
Normal file
23
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
194
components/ui/toast.tsx
Normal file
194
components/ui/toast.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { setNotify } from '../../application/notification';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
title?: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
interface ToastActionsValue {
|
||||
showToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
}
|
||||
|
||||
interface ToastStateValue {
|
||||
toasts: Toast[];
|
||||
}
|
||||
|
||||
const ToastActionsContext = createContext<ToastActionsValue | null>(null);
|
||||
const ToastStateContext = createContext<ToastStateValue | null>(null);
|
||||
|
||||
/** Actions-only subscription — does not re-render when the toast list changes. */
|
||||
export const useToastActions = () => {
|
||||
const context = useContext(ToastActionsContext);
|
||||
if (!context) {
|
||||
throw new Error('useToastActions must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
/** Full toast API. Prefer useToastActions when you do not need the list. */
|
||||
export const useToast = () => {
|
||||
const actions = useContext(ToastActionsContext);
|
||||
const state = useContext(ToastStateContext);
|
||||
if (!actions || !state) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return { ...state, ...actions };
|
||||
};
|
||||
|
||||
// Simple hook for components that may not be inside ToastProvider
|
||||
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
|
||||
|
||||
export interface ToastOptions {
|
||||
title?: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, titleOrOptions?: string | ToastOptions) => {
|
||||
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
||||
globalShowToast?.({ type: 'success', message, duration: 3000, ...options });
|
||||
},
|
||||
error: (message: string, titleOrOptions?: string | ToastOptions) => {
|
||||
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
||||
globalShowToast?.({ type: 'error', message, duration: 5000, ...options });
|
||||
},
|
||||
warning: (message: string, titleOrOptions?: string | ToastOptions) => {
|
||||
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
||||
globalShowToast?.({ type: 'warning', message, duration: 4000, ...options });
|
||||
},
|
||||
info: (message: string, titleOrOptions?: string | ToastOptions) => {
|
||||
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
|
||||
globalShowToast?.({ type: 'info', message, duration: 3000, ...options });
|
||||
},
|
||||
};
|
||||
|
||||
const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
|
||||
success: <CheckCircle className="h-4 w-4 text-emerald-500" />,
|
||||
error: <AlertCircle className="h-4 w-4 text-red-500" />,
|
||||
warning: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
|
||||
info: <Info className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
const TOAST_STYLES: Record<ToastType, string> = {
|
||||
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
|
||||
error: 'border-red-600 bg-red-50 dark:bg-red-950',
|
||||
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
|
||||
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((nextToast: Omit<Toast, 'id'>) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const created: Toast = { ...nextToast, id };
|
||||
setToasts(prev => [...prev, created]);
|
||||
|
||||
// Auto dismiss
|
||||
if (nextToast.duration !== 0) {
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, nextToast.duration || 4000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const actionsValue = useMemo<ToastActionsValue>(
|
||||
() => ({ showToast, dismissToast }),
|
||||
[showToast, dismissToast],
|
||||
);
|
||||
|
||||
const stateValue = useMemo<ToastStateValue>(
|
||||
() => ({ toasts }),
|
||||
[toasts],
|
||||
);
|
||||
|
||||
// Register global toast function
|
||||
useEffect(() => {
|
||||
globalShowToast = showToast;
|
||||
setNotify(toast);
|
||||
return () => {
|
||||
globalShowToast = null;
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<ToastActionsContext.Provider value={actionsValue}>
|
||||
<ToastStateContext.Provider value={stateValue}>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastStateContext.Provider>
|
||||
</ToastActionsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ToastContainer: React.FC = () => {
|
||||
const { toasts } = useContext(ToastStateContext) ?? { toasts: [] as Toast[] };
|
||||
const actions = useContext(ToastActionsContext);
|
||||
const onDismiss = actions?.dismissToast;
|
||||
|
||||
if (toasts.length === 0 || !onDismiss) return null;
|
||||
|
||||
const handleToastClick = (t: Toast) => {
|
||||
if (t.onClick) {
|
||||
t.onClick();
|
||||
onDismiss(t.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg border shadow-lg",
|
||||
"bg-card animate-in slide-in-from-right-5 fade-in duration-200",
|
||||
TOAST_STYLES[t.type],
|
||||
t.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
|
||||
)}
|
||||
onClick={() => handleToastClick(t)}
|
||||
role={t.onClick ? "button" : undefined}
|
||||
tabIndex={t.onClick ? 0 : undefined}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{TOAST_ICONS[t.type]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{t.title && (
|
||||
<div className="text-sm font-medium text-foreground">{t.title}</div>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground break-words">{t.message}</div>
|
||||
{t.actionLabel && t.onClick && (
|
||||
<div className="text-xs font-medium text-primary mt-1">{t.actionLabel} →</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss(t.id); }}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastProvider;
|
||||
285
components/ui/toolbar-item-layout.tsx
Normal file
285
components/ui/toolbar-item-layout.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LayoutList,
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
PanelTop,
|
||||
} from 'lucide-react';
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
import type { ToolbarItemPlacement } from '../../domain/toolbarItemLayout';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from './button';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from './context-menu';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
|
||||
|
||||
const ToolbarOverflowCloseContext = createContext<(() => void) | null>(null);
|
||||
|
||||
/** Close the parent ⋮ overflow menu after a leaf action (no-op outside overflow). */
|
||||
export function useToolbarOverflowClose(): () => void {
|
||||
return useContext(ToolbarOverflowCloseContext) ?? (() => {});
|
||||
}
|
||||
|
||||
export type ToolbarCustomizeItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Icon shown before the item label in the customize menu. */
|
||||
icon?: React.ReactNode;
|
||||
/** When true, hide option is disabled (locked items). */
|
||||
locked?: boolean;
|
||||
/** When false, collapse is not offered (item is show/hide only). Default true. */
|
||||
supportsCollapse?: boolean;
|
||||
};
|
||||
|
||||
export type ToolbarCustomizeContextMenuProps = {
|
||||
items: ToolbarCustomizeItem[];
|
||||
placementOf: (id: string) => ToolbarItemPlacement;
|
||||
onSetPlacement: (id: string, placement: ToolbarItemPlacement) => unknown;
|
||||
onMove?: (id: string, direction: 'earlier' | 'later') => void;
|
||||
onReset: () => void;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
children: React.ReactNode;
|
||||
/** Optional className for the trigger wrapper. */
|
||||
className?: string;
|
||||
/** Optional inline style for the trigger wrapper (chrome theming). */
|
||||
style?: React.CSSProperties;
|
||||
/** Optional data-section marker for custom CSS / tests. */
|
||||
dataSection?: string;
|
||||
/** When false, right-click is disabled (e.g. compact mode). Default true. */
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const PLACEMENT_LABEL_KEYS: Record<ToolbarItemPlacement, string> = {
|
||||
show: 'toolbar.layout.show',
|
||||
collapse: 'toolbar.layout.collapse',
|
||||
hide: 'toolbar.layout.hide',
|
||||
};
|
||||
|
||||
const PLACEMENT_ICONS: Record<ToolbarItemPlacement, React.ReactNode> = {
|
||||
show: <Eye size={12} className="shrink-0 text-muted-foreground" />,
|
||||
collapse: <LayoutList size={12} className="shrink-0 text-muted-foreground" />,
|
||||
hide: <EyeOff size={12} className="shrink-0 text-muted-foreground" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Right-click the toolbar region to configure each action as show / collapse / hide.
|
||||
* Optional move earlier/later reorders the full item list.
|
||||
*/
|
||||
export const ToolbarCustomizeContextMenu: React.FC<ToolbarCustomizeContextMenuProps> = ({
|
||||
items,
|
||||
placementOf,
|
||||
onSetPlacement,
|
||||
onMove,
|
||||
onReset,
|
||||
t,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
dataSection,
|
||||
enabled = true,
|
||||
}) => {
|
||||
if (!enabled || items.length === 0) {
|
||||
return (
|
||||
<div className={className} style={style} data-section={dataSection}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
data-section={dataSection}
|
||||
data-toolbar-customize-root="true"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="min-w-[12rem]">
|
||||
<ContextMenuLabel className="flex items-center gap-2">
|
||||
<PanelTop size={14} className="shrink-0 text-muted-foreground" />
|
||||
{t('toolbar.layout.customize')}
|
||||
</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
{items.map((item, index) => {
|
||||
const placement = placementOf(item.id);
|
||||
return (
|
||||
<ContextMenuSub key={item.id}>
|
||||
<ContextMenuSubTrigger className="gap-2">
|
||||
{item.icon ? (
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-muted-foreground [&>svg]:h-3.5 [&>svg]:w-3.5">
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
|
||||
{PLACEMENT_ICONS[placement]}
|
||||
{t(PLACEMENT_LABEL_KEYS[placement])}
|
||||
</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[10rem]">
|
||||
<ContextMenuRadioGroup
|
||||
value={
|
||||
item.supportsCollapse === false && placement === 'collapse' ? 'show' : placement
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'show' || value === 'collapse' || value === 'hide') {
|
||||
if (value === 'collapse' && item.supportsCollapse === false) return;
|
||||
onSetPlacement(item.id, value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenuRadioItem value="show" className="gap-2">
|
||||
<Eye size={12} className="shrink-0" />
|
||||
{t('toolbar.layout.show')}
|
||||
</ContextMenuRadioItem>
|
||||
{item.supportsCollapse !== false && (
|
||||
<ContextMenuRadioItem value="collapse" className="gap-2">
|
||||
<LayoutList size={12} className="shrink-0" />
|
||||
{t('toolbar.layout.collapse')}
|
||||
</ContextMenuRadioItem>
|
||||
)}
|
||||
<ContextMenuRadioItem value="hide" disabled={item.locked} className="gap-2">
|
||||
<EyeOff size={12} className="shrink-0" />
|
||||
{t('toolbar.layout.hide')}
|
||||
</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
{onMove && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={index === 0}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onMove(item.id, 'earlier');
|
||||
}}
|
||||
>
|
||||
<ChevronUp size={14} className="mr-2" />
|
||||
{t('toolbar.layout.moveEarlier')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={index === items.length - 1}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onMove(item.id, 'later');
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={14} className="mr-2" />
|
||||
{t('toolbar.layout.moveLater')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
})}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => onReset()}>{t('toolbar.layout.reset')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolbarOverflowMenuProps = {
|
||||
/** When empty, the ⋮ trigger is not rendered. */
|
||||
hasItems: boolean;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
/** Icon orientation; terminal uses vertical, sftp horizontal. */
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
buttonClassName?: string;
|
||||
contentClassName?: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
/** Optional access to the persistent trigger, e.g. for restoring focus after a nested dialog. */
|
||||
triggerRef?: React.Ref<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* ⋮ button that opens the collapsed-item region. Hidden when nothing is collapsed.
|
||||
* Uses controlled Popover so leaf actions can close via useToolbarOverflowClose().
|
||||
* Nested portaled menus (encoding, bookmark list) use data-toolbar-nested-menu
|
||||
* to stay open while the nested panel is used.
|
||||
*/
|
||||
export const ToolbarOverflowMenu: React.FC<ToolbarOverflowMenuProps> = ({
|
||||
hasItems,
|
||||
label,
|
||||
children,
|
||||
orientation = 'horizontal',
|
||||
buttonClassName,
|
||||
contentClassName,
|
||||
align = 'end',
|
||||
triggerRef,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
if (!hasItems) return null;
|
||||
const Icon = orientation === 'vertical' ? MoreVertical : MoreHorizontal;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
aria-label={label}
|
||||
data-toolbar-overflow-trigger="true"
|
||||
>
|
||||
<Icon size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
className={cn('p-1 w-auto', contentClassName)}
|
||||
data-toolbar-overflow-menu="true"
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as Element | null;
|
||||
if (target?.closest('[data-toolbar-nested-menu="true"]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Leaf clicks close; nested openers keep the menu for the child panel.
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
if (target.closest('[data-toolbar-overflow-keep-open="true"]')) return;
|
||||
if (target.closest('[data-toolbar-nested-menu="true"]')) return;
|
||||
if (target.closest('button, [role="menuitem"], a')) {
|
||||
// Defer so the leaf onClick still runs first.
|
||||
requestAnimationFrame(() => setOpen(false));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToolbarOverflowCloseContext.Provider value={close}>
|
||||
{children}
|
||||
</ToolbarOverflowCloseContext.Provider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
35
components/ui/tooltip.tsx
Normal file
35
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
import { usePortalContainer } from "./portal-container"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||
const portalContainer = usePortalContainer()
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Portal container={portalContainer ?? undefined}>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-[999999] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
})
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
92
components/ui/virtualListMath.test.ts
Normal file
92
components/ui/virtualListMath.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildItemIndexToVisualIndexMap,
|
||||
clampListIndex,
|
||||
clampScrollTop,
|
||||
getFixedSizeVirtualWindow,
|
||||
stepListIndex,
|
||||
} from './virtualListMath.ts';
|
||||
|
||||
test('clampScrollTop keeps the window on-content after a deep scroll + filter shrink', () => {
|
||||
// User was deep in a long list (scrollTop large), then filter leaves few rows.
|
||||
assert.equal(clampScrollTop(50_000, 10 * 44, 360), Math.max(0, 10 * 44 - 360));
|
||||
assert.equal(clampScrollTop(-10, 1000, 200), 0);
|
||||
assert.equal(clampScrollTop(100, 1000, 200), 100);
|
||||
assert.equal(clampScrollTop(0, 0, 200), 0);
|
||||
});
|
||||
|
||||
test('getFixedSizeVirtualWindow never starts past the last item after shrink', () => {
|
||||
const deep = getFixedSizeVirtualWindow({
|
||||
itemCount: 10_000,
|
||||
itemHeight: 44,
|
||||
scrollTop: 40_000,
|
||||
viewportHeight: 300,
|
||||
overscan: 6,
|
||||
});
|
||||
assert.ok(deep.startIndex < 10_000);
|
||||
assert.ok(deep.endIndex - deep.startIndex < 100);
|
||||
assert.ok(deep.endIndex <= 10_000);
|
||||
|
||||
const shrunk = getFixedSizeVirtualWindow({
|
||||
itemCount: 8,
|
||||
itemHeight: 44,
|
||||
scrollTop: 40_000,
|
||||
viewportHeight: 300,
|
||||
overscan: 6,
|
||||
});
|
||||
assert.equal(shrunk.effectiveScrollTop, Math.max(0, 8 * 44 - 300));
|
||||
assert.ok(shrunk.startIndex >= 0);
|
||||
assert.ok(shrunk.startIndex < 8);
|
||||
assert.equal(shrunk.endIndex, 8);
|
||||
// Window must include at least one real item so the list never blanks.
|
||||
assert.ok(shrunk.endIndex > shrunk.startIndex);
|
||||
});
|
||||
|
||||
test('getFixedSizeVirtualWindow only materializes a viewport-sized slice for large lists', () => {
|
||||
const window = getFixedSizeVirtualWindow({
|
||||
itemCount: 8_000,
|
||||
itemHeight: 44,
|
||||
scrollTop: 0,
|
||||
viewportHeight: 360,
|
||||
overscan: 8,
|
||||
});
|
||||
const rendered = window.endIndex - window.startIndex;
|
||||
assert.ok(rendered > 0);
|
||||
assert.ok(rendered < 100, `expected viewport window, got ${rendered}`);
|
||||
assert.equal(window.startIndex, 0);
|
||||
});
|
||||
|
||||
test('clampListIndex and stepListIndex never produce -1 on empty or non-empty lists', () => {
|
||||
assert.equal(clampListIndex(-1, 0), 0);
|
||||
assert.equal(clampListIndex(5, 0), 0);
|
||||
assert.equal(clampListIndex(-1, 5), 0);
|
||||
assert.equal(clampListIndex(99, 5), 4);
|
||||
assert.equal(clampListIndex(2, 5), 2);
|
||||
|
||||
assert.equal(stepListIndex(0, 0, 1), 0);
|
||||
assert.equal(stepListIndex(0, 0, -1), 0);
|
||||
// Empty list + ArrowDown must not go to -1 (QuickSwitcher regression).
|
||||
assert.equal(stepListIndex(0, 0, 1), 0);
|
||||
assert.equal(stepListIndex(0, 3, 1), 1);
|
||||
assert.equal(stepListIndex(2, 3, 1), 2);
|
||||
assert.equal(stepListIndex(0, 3, -1), 0);
|
||||
});
|
||||
|
||||
test('buildItemIndexToVisualIndexMap skips headers for keyboard scroll targets', () => {
|
||||
const visual = [
|
||||
{ kind: 'header' },
|
||||
{ kind: 'item' },
|
||||
{ kind: 'item' },
|
||||
{ kind: 'header' },
|
||||
{ kind: 'item' },
|
||||
];
|
||||
const map = buildItemIndexToVisualIndexMap(visual);
|
||||
assert.equal(map.get(0), 1);
|
||||
assert.equal(map.get(1), 2);
|
||||
assert.equal(map.get(2), 4);
|
||||
assert.equal(map.size, 3);
|
||||
// Keyboard index 1 scrolls to visual row 2 (second host, after first header).
|
||||
assert.notEqual(map.get(1), 1);
|
||||
});
|
||||
68
components/ui/virtualListMath.ts
Normal file
68
components/ui/virtualListMath.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Pure math for virtual lists and keyboard cursors.
|
||||
* Kept free of React so tests drive the same functions the pickers use.
|
||||
*/
|
||||
|
||||
export function clampScrollTop(
|
||||
scrollTop: number,
|
||||
totalHeight: number,
|
||||
viewportHeight: number,
|
||||
): number {
|
||||
const maxScroll = Math.max(0, totalHeight - Math.max(viewportHeight, 0));
|
||||
return Math.min(Math.max(0, scrollTop), maxScroll);
|
||||
}
|
||||
|
||||
export function getFixedSizeVirtualWindow({
|
||||
itemCount,
|
||||
itemHeight,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
overscan,
|
||||
}: {
|
||||
itemCount: number;
|
||||
itemHeight: number;
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
overscan: number;
|
||||
}): { startIndex: number; endIndex: number; effectiveScrollTop: number; totalHeight: number } {
|
||||
const totalHeight = Math.max(0, itemCount * itemHeight);
|
||||
const effectiveScrollTop = clampScrollTop(scrollTop, totalHeight, viewportHeight);
|
||||
if (itemCount <= 0 || itemHeight <= 0) {
|
||||
return { startIndex: 0, endIndex: 0, effectiveScrollTop, totalHeight };
|
||||
}
|
||||
const startIndex = Math.max(0, Math.floor(effectiveScrollTop / itemHeight) - overscan);
|
||||
const visibleCount = Math.ceil(Math.max(viewportHeight, 1) / itemHeight) + overscan * 2;
|
||||
const endIndex = Math.min(itemCount, startIndex + visibleCount);
|
||||
return { startIndex, endIndex, effectiveScrollTop, totalHeight };
|
||||
}
|
||||
|
||||
/** Clamp a keyboard cursor into [0, length-1], or 0 when the list is empty. */
|
||||
export function clampListIndex(index: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
if (!Number.isFinite(index)) return 0;
|
||||
return Math.max(0, Math.min(Math.trunc(index), length - 1));
|
||||
}
|
||||
|
||||
/** Move a keyboard cursor by delta without ever landing on -1. */
|
||||
export function stepListIndex(index: number, length: number, delta: number): number {
|
||||
if (length <= 0) return 0;
|
||||
return clampListIndex(index + delta, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map selectable item indices to visual row indices for lists that interleave
|
||||
* non-selectable chrome (headers, hints, empty rows).
|
||||
*/
|
||||
export function buildItemIndexToVisualIndexMap(
|
||||
visualRows: ReadonlyArray<{ kind: string }>,
|
||||
itemKind = 'item',
|
||||
): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
let itemIndex = 0;
|
||||
visualRows.forEach((row, visualIndex) => {
|
||||
if (row.kind !== itemKind) return;
|
||||
map.set(itemIndex, visualIndex);
|
||||
itemIndex += 1;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
Reference in New Issue
Block a user