chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,51 @@
'use client'
import type React from 'react'
import { memo } from 'react'
import { cn, FloatingTooltip, isTextClipped, useFloatingTooltip, useIsOverflowing } from '@sim/emcn'
interface FloatingOverflowTextProps {
/** Full text shown in the tooltip and used as the default visible content. */
label: string
/** Optional custom visible content (e.g. highlighted text); defaults to `label`. */
children?: React.ReactNode
className?: string
/** Forces the tooltip even when the text is not visually clipped (e.g. content truncated upstream). */
showWhen?: boolean
}
/**
* Truncating text that fades its clipped edge and reveals the full value in a
* pointer-reactive floating tooltip on hover or focus.
*/
export const FloatingOverflowText = memo(function FloatingOverflowText({
label,
children,
className,
showWhen,
}: FloatingOverflowTextProps) {
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
const { state, handlers } = useFloatingTooltip(() => {
const element = node.current
if (!element || label.length === 0) return false
return Boolean(showWhen) || isTextClipped(element)
})
return (
<>
<span
ref={textRef}
className={cn(
'min-w-0',
isOverflowing &&
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] hover:[mask-image:none] focus-visible:[mask-image:none]',
className
)}
{...handlers}
>
{children ?? label}
</span>
<FloatingTooltip label={label} state={state} />
</>
)
})
@@ -0,0 +1 @@
export { ownerCell } from './owner-cell'
@@ -0,0 +1,52 @@
import { memo } from 'react'
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
import type { WorkspaceMember } from '@/hooks/queries/workspace'
interface OwnerAvatarProps {
name: string
image: string | null
}
const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) {
if (image) {
return (
<img
src={image}
alt={name}
referrerPolicy='no-referrer'
className='size-[14px] rounded-full border border-[var(--border)] object-cover'
/>
)
}
return (
<span className='flex size-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{name.charAt(0).toUpperCase()}
</span>
)
})
/**
* Resolves a user ID into a ResourceCell with an avatar icon and display name.
* Returns null label while members are still loading to avoid flashing raw IDs.
*
* Accepts either the raw member array or a precomputed `userId → member` map.
* Prefer the map form when resolving many rows so lookups stay O(1) instead of
* scanning the array per row.
*/
export function ownerCell(
userId: string | null | undefined,
members?: WorkspaceMember[] | Map<string, WorkspaceMember>
): ResourceCell {
if (!userId) return { label: null }
if (!members) return { label: null }
const member =
members instanceof Map ? members.get(userId) : members.find((m) => m.userId === userId)
if (!member) return { label: null }
return {
icon: <OwnerAvatar name={member.name} image={member.image} />,
label: member.name,
}
}
@@ -0,0 +1,2 @@
export type { ChromeActionSpec } from './resource-chrome-fallback'
export { ResourceChromeFallback } from './resource-chrome-fallback'
@@ -0,0 +1,91 @@
'use client'
import type { ComponentType } from 'react'
import { noop } from '@sim/utils/helpers'
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
import {
Resource,
type ResourceColumn,
} from '@/app/workspace/[workspaceId]/components/resource/resource'
/**
* The static visual shape of a header action chip. The loading fallback only
* needs the chrome (text/icon/variant/active) — handlers are no-ops during a
* route transition — so the dynamic `onSelect`/`disabled` are intentionally
* omitted. Mirrors {@link ResourceAction}'s chrome fields.
*/
export interface ChromeActionSpec {
text: string
icon?: ComponentType<{ className?: string }>
variant?: 'primary' | 'destructive'
active?: boolean
}
interface ResourceChromeFallbackProps {
/** Title-mode icon (list pages) or breadcrumb-root icon (detail pages). */
icon?: ComponentType<{ className?: string }>
/** Title-mode label. Omit when `breadcrumbs` is supplied. */
title?: string
/**
* Static breadcrumb trail for detail-route fallbacks. The live leaf name is
* unknown at load, so pass the known root crumb(s) plus a terminal `…`
* placeholder; it fills in once the page mounts.
*/
breadcrumbs?: BreadcrumbItem[]
/** Table column headers. Omit on bodies that don't render `Resource.Table` (the table editor). */
columns?: ResourceColumn[]
/** The page's exact header action chips (handlers wired to a no-op). */
actions?: ChromeActionSpec[]
/** Search placeholder. Omit to hide the search box (matches a page with no search). */
searchPlaceholder?: string
/** Paint the Sort chip (its menu never opens during the fallback, so the option list is irrelevant). */
hasSort?: boolean
/** Paint the Filter chip. */
hasFilter?: boolean
}
/**
* Route-transition fallback rendered by each resource route's `loading.tsx`. It
* paints the REAL resource chrome — the header (icon/title or breadcrumbs + the
* page's exact action chips), the options bar (search + the Filter/Sort chips),
* and the table's column headers — with an empty body and no-op handlers, so a
* navigation never shows a blank frame or a skeleton. Only the breadcrumb leaf
* and the row data are unknown at load; everything else matches the loaded page.
*/
export function ResourceChromeFallback({
icon,
title,
breadcrumbs,
columns,
actions,
searchPlaceholder,
hasSort = false,
hasFilter = false,
}: ResourceChromeFallbackProps) {
return (
<Resource>
<Resource.Header
icon={icon}
title={title}
breadcrumbs={breadcrumbs}
actions={actions?.map((action) => ({
text: action.text,
icon: action.icon,
variant: action.variant,
active: action.active,
onSelect: noop,
}))}
/>
<Resource.Options
search={
searchPlaceholder !== undefined
? { value: '', onChange: noop, placeholder: searchPlaceholder }
: undefined
}
sort={hasSort ? { options: [], active: null, onSort: noop } : undefined}
filter={hasFilter ? { content: null } : undefined}
/>
{columns ? <Resource.Table columns={columns} rows={[]} /> : null}
</Resource>
)
}
@@ -0,0 +1,7 @@
export type {
BreadcrumbEditing,
BreadcrumbItem,
DropdownOption,
ResourceAction,
} from './resource-header'
export { ResourceHeader } from './resource-header'
@@ -0,0 +1,657 @@
import {
type ComponentType,
Fragment,
forwardRef,
memo,
type ReactNode,
useEffect,
useRef,
useState,
} from 'react'
import {
Chip,
ChipChevronDown,
chipContentIconClass,
chipGeometryClass,
chipVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FloatingTooltip,
POPOVER_ANIMATION_CLASSES,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverSection,
useFloatingTooltip,
useIsOverflowing,
} from '@sim/emcn'
import { ArrowUpLeft } from 'lucide-react'
import { createPortal } from 'react-dom'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
export interface DropdownOption {
label: string
icon?: React.ElementType
onClick: () => void
disabled?: boolean
}
export interface BreadcrumbEditing {
isEditing: boolean
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
/**
* Disables the rename field while the save is in flight, mirroring
* {@link ResourceCellEditing.disabled} on table cells. Threaded from
* `useInlineRename`'s `isSaving`. Optional so existing consumers keep
* working unchanged.
*/
disabled?: boolean
}
export interface BreadcrumbItem {
label: string
icon?: React.ElementType
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
/**
* Marks a non-navigable trailing crumb (e.g. "New Chunk", "Loading...") so the
* header sizes it as the terminal segment rather than the current resource.
*/
terminal?: boolean
}
/**
* The single, strict contract for a top-right header action. Every action renders
* as a {@link Chip} — consumers describe intent through these fields and nothing
* else, so the action row looks identical on every page and cannot drift. Omit
* `variant` for the default chip; use `primary`/`destructive` for emphasis. Express
* a selected/toggle state with `active` (e.g. the Logs/Dashboard view toggle).
*/
export interface ResourceAction {
icon?: ComponentType<{ className?: string }>
text: string
variant?: 'primary' | 'destructive'
active?: boolean
onSelect: () => void
disabled?: boolean
}
interface ResourceHeaderProps {
icon?: React.ElementType
title?: string
breadcrumbs?: BreadcrumbItem[]
/** Strict top-right action chips. List pages use ONLY this. */
actions?: ResourceAction[]
/**
* Supplementary right-aligned content rendered before `actions` — custom
* widgets that cannot collapse into the strict {@link ResourceAction} chip
* contract, e.g. the table editor's run/stop control, an import-progress
* menu, or a create dropdown. Anything that fits the chip contract belongs
* in `actions`; never stuff primary actions in here.
*/
aside?: ReactNode
}
export const ResourceHeader = memo(function ResourceHeader({
icon: Icon,
title,
breadcrumbs,
actions,
aside,
}: ResourceHeaderProps) {
const headerRef = useRef<HTMLDivElement>(null)
/**
* Breadcrumb mode is reserved for nested pages (length > 1). A single-crumb
* "breadcrumb" is just the current page, so it falls through to the static
* title below — keeping the top-left non-interactive and hover-free,
* identical to a title-only page (e.g. the Files root matches the Tables root).
*/
const hasBreadcrumbs = breadcrumbs != null && breadcrumbs.length > 1
const rootCrumb = breadcrumbs?.length === 1 ? breadcrumbs[0] : undefined
const TitleIcon = Icon ?? rootCrumb?.icon
const titleLabel = title ?? rootCrumb?.label
const terminalBreadcrumbIndex =
hasBreadcrumbs && breadcrumbs[breadcrumbs.length - 1].terminal ? breadcrumbs.length - 1 : -1
const currentResourceIndex =
terminalBreadcrumbIndex > -1
? terminalBreadcrumbIndex - 1
: hasBreadcrumbs && breadcrumbs.length > 2
? breadcrumbs.length - 1
: -1
return (
<div
ref={headerRef}
className='flex min-h-[48px] items-center border-[var(--border)] border-b px-4 py-[8.5px]'
>
<div className='flex min-w-0 flex-1 items-center justify-between gap-3'>
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
{hasBreadcrumbs ? (
breadcrumbs.map((crumb, i) => {
const segmentClassName = getBreadcrumbSegmentClassName(
i,
breadcrumbs.length,
currentResourceIndex,
terminalBreadcrumbIndex
)
const LocationIcon = i === 0 ? (crumb.icon ?? Icon) : undefined
/**
* The first crumb on a nested page opens the hover "path" popover
* (back-navigation). Single-crumb roots never reach here — they
* render as the static title above.
*/
const showLocationPopover = LocationIcon != null
return (
<Fragment key={`${crumb.label}-${i}`}>
{i > 0 && (
<span className='mx-0.5 shrink-0 select-none text-[var(--text-icon)] text-sm'>
/
</span>
)}
{showLocationPopover ? (
<BreadcrumbLocationPopover
icon={LocationIcon}
breadcrumbs={breadcrumbs}
className={segmentClassName}
veilBoundaryRef={headerRef}
/>
) : (
<BreadcrumbSegment
icon={LocationIcon ?? crumb.icon}
label={crumb.label}
onClick={crumb.onClick}
dropdownItems={crumb.dropdownItems}
editing={crumb.editing}
className={segmentClassName}
/>
)}
</Fragment>
)
})
) : (
/**
* Root titles are short static labels ("Tables", "Files"), so the
* span is non-shrinkable and the label never truncates — matching
* the `shrink-0` guarantee the breadcrumb root crumb gets from
* {@link getBreadcrumbSegmentClassName}. Without this, the
* `flex-1` left column collapses during transient initial-load
* layout (the JS-driven `--sidebar-width` settling) and the title
* CSS-truncates to "T…" while the `shrink-0` actions hold width.
*/
<span className={cn(chipGeometryClass, 'inline-flex shrink-0 cursor-default')}>
{TitleIcon && <TitleIcon className={chipContentIconClass} />}
{titleLabel && (
<FloatingOverflowText
label={titleLabel}
className='block whitespace-nowrap text-[var(--text-body)] text-sm'
/>
)}
</span>
)}
</div>
{(aside || (actions && actions.length > 0)) && (
<div className='flex shrink-0 items-center'>
{aside}
{actions?.map((action) => (
<Chip
key={action.text}
variant={action.variant}
active={action.active}
leftIcon={action.icon}
onClick={action.onSelect}
disabled={action.disabled}
>
{action.text}
</Chip>
))}
</div>
)}
</div>
</div>
)
})
function getBreadcrumbSegmentClassName(
index: number,
total: number,
currentResourceIndex: number,
terminalBreadcrumbIndex: number
): string {
if (index === terminalBreadcrumbIndex) {
return 'shrink-0 max-w-[10rem]'
}
if (index === 0) {
return 'shrink-0'
}
if (currentResourceIndex > -1) {
if (index === currentResourceIndex) {
return 'min-w-0 flex-[0_1_auto] max-w-[56%]'
}
return 'min-w-0 flex-[0_1_auto] max-w-[34%]'
}
if (total > 2) {
return 'min-w-0 flex-[0_1_auto] max-w-[42%]'
}
return 'min-w-0 flex-[0_1_auto] max-w-[min(32rem,55vw)]'
}
interface BreadcrumbSegmentProps {
icon?: React.ElementType
label: string
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
className?: string
}
const BreadcrumbSegment = memo(function BreadcrumbSegment({
icon: Icon,
label,
onClick,
dropdownItems,
editing,
className,
}: BreadcrumbSegmentProps) {
const { ref: labelRef, node: labelNode, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
const { state: tooltipState, handlers: tooltipHandlers } = useFloatingTooltip((target) =>
isBreadcrumbTextClipped(labelNode.current, target)
)
if (editing?.isEditing) {
return (
<span className={cn(chipGeometryClass, 'inline-flex min-w-0 justify-start', className)}>
{Icon && <Icon className={chipContentIconClass} />}
<InlineRenameInput
value={editing.value}
onChange={editing.onChange}
onSubmit={editing.onSubmit}
onCancel={editing.onCancel}
disabled={editing.disabled}
/>
</span>
)
}
const content = (
<>
{Icon && <Icon className={chipContentIconClass} />}
<BreadcrumbLabel ref={labelRef} isOverflowing={isOverflowing} label={label} />
</>
)
/**
* Interactive crumbs use a plain `<button>` with bare-chip geometry — NEVER
* the Button component, whose buttonVariants inject font-medium /
* rounded-[5px] / justify-center and break chip parity with the static/title
* crumbs.
*/
const triggerClassName = cn(
chipVariants({ flush: true }),
'group min-w-0 max-w-full justify-start'
)
if (dropdownItems && dropdownItems.length > 0) {
return (
<>
<DropdownMenu>
<FloatingTooltip label={label} state={tooltipState} />
<DropdownMenuTrigger asChild>
<button type='button' className={cn(triggerClassName, className)} {...tooltipHandlers}>
{content}
<ChipChevronDown className='ml-auto' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{dropdownItems.map((item) => {
const ItemIcon = item.icon
return (
<DropdownMenuItem key={item.label} onClick={item.onClick} disabled={item.disabled}>
{ItemIcon && <ItemIcon className='size-[14px]' />}
{item.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
if (onClick) {
return (
<>
<FloatingTooltip label={label} state={tooltipState} />
<button
type='button'
className={cn(triggerClassName, className)}
onClick={onClick}
{...tooltipHandlers}
>
{content}
</button>
</>
)
}
return (
<>
<FloatingTooltip label={label} state={tooltipState} />
<span
className={cn(
chipGeometryClass,
'group inline-flex min-w-0 max-w-full cursor-default justify-start',
className
)}
{...tooltipHandlers}
>
{content}
</span>
</>
)
})
interface BreadcrumbLocationPopoverProps {
icon: React.ElementType
breadcrumbs: BreadcrumbItem[]
className?: string
veilBoundaryRef: React.RefObject<HTMLDivElement | null>
}
/**
* Grace period before a hover-out dismisses the path popover. Covers the gap
* the pointer crosses between the trigger and the popover content (and brief
* jitter at their edges); re-entering either within this window cancels the
* close. Standard hover-intent close delay — not tied to any navigation timing.
*/
const POPOVER_CLOSE_DELAY_MS = 120
function BreadcrumbLocationPopover({
icon: Icon,
breadcrumbs,
className,
veilBoundaryRef,
}: BreadcrumbLocationPopoverProps) {
const [open, setOpen] = useState(false)
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rootBreadcrumb = breadcrumbs[0]
const cancelScheduledClose = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current)
closeTimeoutRef.current = null
}
}
/**
* Hover-intent open. Driven only by pointer-/keyboard-enter — never by
* pointer movement. This is what makes the popover dismiss cleanly on a
* click-to-navigate: a stationary click fires no enter event, so once
* {@link navigateAndClose} sets `open` false nothing re-opens it before the
* route swaps. (A move-driven open would re-fire under the resting cursor and
* flash the popover/veil back in mid-navigation.)
*/
const openPopover = () => {
cancelScheduledClose()
setOpen(true)
}
const scheduleClose = () => {
cancelScheduledClose()
closeTimeoutRef.current = setTimeout(() => {
setOpen(false)
closeTimeoutRef.current = null
}, POPOVER_CLOSE_DELAY_MS)
}
/**
* Closes the popover up front, then runs the crumb's handler. Closing first
* lets the veil fade and the popover play its exit animation instead of
* snapping away when navigation unmounts the header.
*/
const navigateAndClose = (onClick?: () => void) => {
if (!onClick) return
cancelScheduledClose()
setOpen(false)
onClick()
}
useEffect(() => {
return () => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
}
}, [])
return (
<>
<LocationFocusVeil visible={open} boundaryRef={veilBoundaryRef} />
<Popover size='md' open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<button
type='button'
aria-label={rootBreadcrumb?.label ?? 'Path'}
onClick={() => navigateAndClose(rootBreadcrumb?.onClick)}
onFocus={openPopover}
onBlur={scheduleClose}
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
className={cn(
chipVariants({ flush: true }),
'max-w-none gap-1.5 px-2 transition-colors',
open && 'relative z-[var(--z-popover)]',
className
)}
>
<span className='relative inline-grid size-[16px] shrink-0 place-items-center'>
<Icon className='col-start-1 row-start-1 size-[16px] text-[var(--text-icon)] opacity-100 blur-0 transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
<ArrowUpLeft
strokeWidth={1.55}
className='col-start-1 row-start-1 size-[16px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-0 motion-reduce:transition-none'
/>
</span>
{rootBreadcrumb?.label && (
<span className='shrink-0 truncate text-[var(--text-body)] text-sm'>
{rootBreadcrumb.label}
</span>
)}
</button>
</PopoverAnchor>
<PopoverContent
side='bottom'
align='start'
sideOffset={6}
minWidth={220}
maxWidth={300}
maxHeight={420}
border
className={cn(
POPOVER_ANIMATION_CLASSES,
'bg-[var(--bg)] p-1.5 text-[var(--text-body)] shadow-sm'
)}
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
>
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
<span className='inline-flex items-center gap-1'>
<span>Path</span>
<span className='opacity-70'>/</span>
</span>
</PopoverSection>
<div className='flex flex-col gap-0.5'>
{breadcrumbs.map((crumb, index) => (
<BreadcrumbLocationItem
key={`${crumb.label}-${index}`}
icon={crumb.icon || (index === 0 ? Icon : undefined)}
label={crumb.label}
onClick={crumb.onClick ? () => navigateAndClose(crumb.onClick) : undefined}
active={index === breadcrumbs.length - 1}
/>
))}
</div>
</PopoverContent>
</Popover>
</>
)
}
function LocationFocusVeil({
visible,
boundaryRef,
}: {
visible: boolean
boundaryRef: React.RefObject<HTMLDivElement | null>
}) {
const [bounds, setBounds] = useState({ top: 0, left: 0 })
/**
* Portal-mount gate. The veil must render `null` on BOTH the server render
* and the first client (hydration) render — branching on
* `typeof document === 'undefined'` made the two renders diverge, which
* failed hydration and forced React to regenerate the whole page tree on
* the client (a visible header flash during load).
*/
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!visible) return
const updateBounds = () => {
const boundary = boundaryRef.current
if (!boundary) return
const rect = boundary.getBoundingClientRect()
setBounds({ top: rect.top, left: rect.left })
}
updateBounds()
window.addEventListener('resize', updateBounds)
window.addEventListener('scroll', updateBounds, true)
return () => {
window.removeEventListener('resize', updateBounds)
window.removeEventListener('scroll', updateBounds, true)
}
}, [boundaryRef, visible])
if (!mounted) return null
return createPortal(
<div
aria-hidden='true'
className={cn(
'pointer-events-none fixed right-0 bottom-0 z-[calc(var(--z-popover)-1)] bg-[var(--bg)] transition-opacity duration-150 ease-out motion-reduce:transition-none',
visible ? 'opacity-60' : 'opacity-0'
)}
style={{ top: bounds.top, left: bounds.left }}
/>,
document.body
)
}
interface BreadcrumbLocationItemProps {
icon?: React.ElementType
label: string
onClick?: () => void
active: boolean
}
function BreadcrumbLocationItem({
icon: Icon,
label,
onClick,
active,
}: BreadcrumbLocationItemProps) {
const labelContent = (
<>
<span className='flex size-[18px] shrink-0 items-center justify-center'>
{Icon ? (
<Icon className='size-3 text-[var(--text-icon)]' />
) : (
<span className='size-1.5 rounded-full bg-[var(--text-muted)]' />
)}
</span>
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
</>
)
if (onClick) {
return (
<PopoverItem
active={active}
onClick={onClick}
className='h-7 items-center gap-1.5 px-1.5 py-0 text-xs'
>
{labelContent}
</PopoverItem>
)
}
return (
<div
className={cn(
'flex h-7 min-w-0 items-center gap-1.5 rounded-lg px-1.5 text-[var(--text-body)] text-xs',
active && 'bg-[var(--surface-active)]'
)}
>
{labelContent}
</div>
)
}
const BreadcrumbLabel = memo(
forwardRef<HTMLSpanElement, BreadcrumbLabelProps>(function BreadcrumbLabel(
{ isOverflowing, label },
ref
) {
return (
<span
ref={ref}
className={cn(
'min-w-0 truncate text-[var(--text-body)]',
isOverflowing &&
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] group-hover:[mask-image:none] group-focus-visible:[mask-image:none]'
)}
>
{label}
</span>
)
})
)
interface BreadcrumbLabelProps {
isOverflowing: boolean
label: string
}
function isBreadcrumbTextClipped(
labelElement: HTMLSpanElement | null,
triggerElement: HTMLElement
): boolean {
if (!labelElement) return false
const labelWidth = labelElement.getBoundingClientRect().width
const triggerWidth = triggerElement.getBoundingClientRect().width
const visibleLabelWidth = Math.min(labelWidth, triggerWidth)
return (
labelElement.scrollWidth > labelElement.clientWidth + 1 ||
triggerElement.scrollWidth > triggerElement.clientWidth + 1 ||
labelElement.scrollWidth > visibleLabelWidth + 1
)
}
@@ -0,0 +1,9 @@
export type {
ColumnOption,
FilterConfig,
FilterTag,
SearchConfig,
SearchTag,
SortConfig,
} from './resource-options'
export { ResourceOptions, SortDropdown } from './resource-options'
@@ -0,0 +1,300 @@
import { memo, type ReactNode, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Chip,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
ListFilter,
POPOVER_ANIMATION_CLASSES,
Search,
X,
} from '@sim/emcn'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
const SEARCH_ICON = (
<Search className='pointer-events-none size-[14px] shrink-0 text-[var(--text-muted)]' />
)
const RESOURCE_MENU_EDGE_OFFSET = 6
type SortDirection = 'asc' | 'desc'
export interface ColumnOption {
id: string
label: string
type?: string
icon?: React.ElementType
}
export interface SortConfig {
options: ColumnOption[]
active: { column: string; direction: SortDirection } | null
onSort: (column: string, direction: SortDirection) => void
onClear?: () => void
}
export interface FilterTag {
label: string
onRemove: () => void
}
export interface SearchTag {
label: string
value: string
onRemove: () => void
}
export interface SearchConfig {
value: string
onChange: (value: string) => void
placeholder?: string
inputRef?: React.RefObject<HTMLInputElement | null>
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onFocus?: () => void
onBlur?: () => void
tags?: SearchTag[]
highlightedTagIndex?: number | null
onClearAll?: () => void
dropdown?: ReactNode
dropdownRef?: React.RefObject<HTMLDivElement | null>
}
/**
* The Filter control has two shapes, picked by `mode`:
* - `popover` (default): list pages pass the filter controls as `content`; the
* button opens them in a popover. `active` highlights the button.
* - `toggle`: detail views (e.g. the table editor) that render their filter as a
* separate panel toggle the button instead of opening a popover.
*/
export type FilterConfig =
| { mode?: 'popover'; content: ReactNode; active?: boolean }
| { mode: 'toggle'; active: boolean; onToggle: () => void }
interface ResourceOptionsProps {
search?: SearchConfig
sort?: SortConfig
filter?: FilterConfig
filterTags?: FilterTag[]
/**
* Lightweight control rendered immediately to the LEFT of the filter/sort
* cluster, forming one group with it — e.g. the knowledge view's
* connected-source badge or the table editor's embedded run/stop control. With
* a search the group is pushed right (opposite the search); without one it
* stays left-aligned (the embedded table editor). Keep it to badges/status
* widgets; primary actions belong in the header's `actions`.
*/
aside?: ReactNode
}
export const ResourceOptions = memo(function ResourceOptions({
search,
sort,
filter,
filterTags,
aside,
}: ResourceOptionsProps) {
/**
* Coordinates the Filter popover and Sort menu as a single menu bar: clicking
* one while the other is open switches to it in a single click. Functional
* updates make the close→open ordering race-proof, so whichever menu the click
* targets wins regardless of which `onOpenChange` fires first.
*/
const [openMenu, setOpenMenu] = useState<'filter' | 'sort' | null>(null)
const isToggleFilter = filter?.mode === 'toggle'
const popoverFilter = filter && filter.mode !== 'toggle' ? filter : null
const hasContent = search || sort || filter || aside || (filterTags && filterTags.length > 0)
if (!hasContent) return null
return (
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
<div className='flex items-center'>
{search && <SearchSection search={search} />}
<div className={cn('flex shrink-0 items-center gap-1.5', search && 'ml-auto')}>
{aside}
<div className='flex items-center'>
{filterTags?.map((tag) => (
<Chip key={tag.label} rightIcon={X} onClick={tag.onRemove}>
{tag.label}
</Chip>
))}
{isToggleFilter && filter.mode === 'toggle' ? (
<Chip active={filter.active} leftIcon={ListFilter} onClick={filter.onToggle}>
Filter
</Chip>
) : popoverFilter ? (
<PopoverPrimitive.Root
open={openMenu === 'filter'}
onOpenChange={(open) =>
setOpenMenu((current) =>
open ? 'filter' : current === 'filter' ? null : current
)
}
>
<PopoverPrimitive.Anchor asChild>
<div className='flex items-center'>
<PopoverPrimitive.Trigger asChild>
<Chip active={popoverFilter.active} leftIcon={ListFilter}>
Filter
</Chip>
</PopoverPrimitive.Trigger>
{sort && (
<SortDropdown
config={sort}
open={openMenu === 'sort'}
onOpenChange={(open) =>
setOpenMenu((current) =>
open ? 'sort' : current === 'sort' ? null : current
)
}
/>
)}
</div>
</PopoverPrimitive.Anchor>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align='end'
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
collisionPadding={6}
sideOffset={6}
className={cn(
POPOVER_ANIMATION_CLASSES,
'z-50 w-fit origin-[--radix-popover-content-transform-origin] rounded-xl border border-[var(--border)] bg-[var(--bg)] shadow-sm'
)}
>
{popoverFilter.content}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
) : null}
{sort && (isToggleFilter || !popoverFilter) && <SortDropdown config={sort} />}
</div>
</div>
</div>
</div>
)
})
const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) {
return (
<div className='relative flex flex-1 items-center gap-1.5'>
{SEARCH_ICON}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{search.tags?.map((tag, i) => (
<Chip
key={`${tag.label}-${tag.value}`}
rightIcon={X}
onClick={tag.onRemove}
active={search.highlightedTagIndex === i}
className='max-w-[280px] shrink-0'
>
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block truncate'>
{tag.label}: {tag.value}
</FloatingOverflowText>
</Chip>
))}
<input
ref={search.inputRef}
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
onKeyDown={search.onKeyDown}
onFocus={search.onFocus}
onBlur={search.onBlur}
placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')}
className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
/>
</div>
{search.tags?.length || search.value ? (
<button
type='button'
className='mr-0.5 flex size-[14px] shrink-0 items-center justify-center text-[var(--text-muted)] transition-colors hover-hover:text-[var(--text-body)]'
onClick={search.onClearAll ?? (() => search.onChange(''))}
>
<span className='text-caption'></span>
</button>
) : null}
{search.dropdown && (
<div
ref={search.dropdownRef}
className='absolute top-full left-0 z-50 mt-1.5 w-full rounded-lg border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
{search.dropdown}
</div>
)}
</div>
)
})
interface SortDropdownProps {
config: SortConfig
/** Controlled open state — omit for standalone (uncontrolled) usage. */
open?: boolean
/** Controlled open-change handler, paired with {@link SortDropdownProps.open}. */
onOpenChange?: (open: boolean) => void
}
export const SortDropdown = memo(function SortDropdown({
config,
open,
onOpenChange,
}: SortDropdownProps) {
const { options, active, onSort, onClear } = config
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Chip active={Boolean(active)} leftIcon={ArrowUpDown}>
Sort
</Chip>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
>
{active && onClear && (
<>
<DropdownMenuItem onSelect={onClear}>
<X />
Clear sort
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{options.map((option) => {
const isActive = active?.column === option.id
const Icon = option.icon
const DirectionIcon = isActive ? (active.direction === 'asc' ? ArrowUp : ArrowDown) : null
return (
<DropdownMenuItem
key={option.id}
onSelect={() => {
if (isActive) {
onSort(option.id, active.direction === 'asc' ? 'desc' : 'asc')
} else {
onSort(option.id, 'desc')
}
}}
>
{Icon && <Icon />}
{option.label}
{DirectionIcon && (
<DirectionIcon className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
})
@@ -0,0 +1,81 @@
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const ORDINAL_RULES: [number, string][] = [
[1, 'st'],
[2, 'nd'],
[3, 'rd'],
]
function ordinalSuffix(day: number): string {
if (day >= 11 && day <= 13) return 'th'
return ORDINAL_RULES.find(([d]) => day % 10 === d)?.[1] ?? 'th'
}
const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const
function formatFullDate(date: Date): string {
const month = MONTH_NAMES[date.getMonth()]
const day = date.getDate()
const year = date.getFullYear()
return `${month} ${day}${ordinalSuffix(day)}, ${year}`
}
function pluralize(value: number, unit: string): string {
return `${value} ${unit}${value === 1 ? '' : 's'}`
}
/**
* Formats a date string into a human-friendly relative time label.
*
* - Within ~1 minute of now: "Now"
* - Under 1 hour: "X minute(s) ago" / "X minute(s)"
* - Under 24 hours: "X hour(s) ago" / "X hour(s)"
* - Under 2 days: "X day(s) ago" / "X day(s)"
* - Beyond 2 days: full date (e.g. "March 4th, 2026")
*/
export function timeCell(dateValue: string | Date | null | undefined): ResourceCell {
if (!dateValue) return { label: null }
const date = dateValue instanceof Date ? dateValue : new Date(dateValue)
const now = new Date()
const diff = now.getTime() - date.getTime()
const absDiff = Math.abs(diff)
const isPast = diff > 0
if (absDiff < MINUTE) return { label: 'Now' }
if (absDiff < HOUR) {
const minutes = Math.floor(absDiff / MINUTE)
return { label: isPast ? `${pluralize(minutes, 'minute')} ago` : pluralize(minutes, 'minute') }
}
if (absDiff < DAY) {
const hours = Math.floor(absDiff / HOUR)
return { label: isPast ? `${pluralize(hours, 'hour')} ago` : pluralize(hours, 'hour') }
}
if (absDiff < 2 * DAY) {
const days = Math.floor(absDiff / DAY)
return { label: isPast ? `${pluralize(days, 'day')} ago` : pluralize(days, 'day') }
}
return { label: formatFullDate(date) }
}
@@ -0,0 +1,701 @@
'use client'
import {
type CSSProperties,
type DragEvent,
memo,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {
Button,
Checkbox,
cellIconNodeClass,
chipContentGap,
chipContentLabelClass,
cn,
Loader,
} from '@sim/emcn'
import { useVirtualizer } from '@tanstack/react-virtual'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options'
export interface ResourceColumn {
id: string
header: string
widthMultiplier?: number
}
export interface ResourceCellEditing {
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
/**
* Disables the rename field while the save is in flight, mirroring the
* sidebar's `disabled={isRenaming}`. Threaded from `useInlineRename`'s
* `isSaving`. Optional so existing consumers keep working unchanged.
*/
disabled?: boolean
}
export interface ResourceCell {
icon?: ReactNode
label?: string | null
content?: ReactNode
/**
* When set, the cell renders an inline rename field inside the canonical cell
* chrome (icon + {@link InlineRenameInput}). Consumers pass structured handlers
* instead of hand-rolling a `content` node, so every rename cell matches the
* resting cell exactly (same gap, weight, icon size).
*/
editing?: ResourceCellEditing
}
export interface ResourceRow {
id: string
cells: Record<string, ResourceCell>
}
export interface SelectableConfig {
selectedIds: Set<string>
onSelectRow: (id: string, checked: boolean, shiftKey?: boolean) => void
onSelectAll: (checked: boolean) => void
isAllSelected: boolean
disabled?: boolean
}
export interface RowDragDropConfig {
activeDropTargetId?: string | null
draggedRowIds?: Set<string>
isAnyDragActive?: boolean
isRowDraggable?: (rowId: string) => boolean
isRowDropTarget?: (rowId: string) => boolean
onDragStart?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragOver?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragLeave?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDrop?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
onDragEnd?: (e: DragEvent<HTMLDivElement>, rowId: string) => void
}
export interface PaginationConfig {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
export const EMPTY_CELL_PLACEHOLDER = '—'
/**
* Seed height (px) for each virtualized row before it is measured. Every
* consumer renders single-line `py-2.5` cells, so this matches the resting row
* height closely; `measureElement` then corrects each row to its exact pixel
* height after mount, so the estimate only affects pre-measure scroll math.
*/
const ROW_HEIGHT_ESTIMATE = 41 as const
/** Rows rendered above/below the viewport to avoid blank flashes on fast scroll. */
const ROW_OVERSCAN = 8 as const
const CHECKBOX_COLUMN_WIDTH = '52px'
/**
* Builds the shared CSS grid track list for the header and every body row from
* the same first-column-weighted ratios the legacy `<colgroup>` used, so the
* virtualized grid layout reproduces the exact column widths. The checkbox
* column, when present, is a fixed leading track.
*/
function buildGridTemplateColumns(columns: ResourceColumn[], hasCheckbox: boolean): string {
const weights = columns.map(
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
)
const total = weights.reduce((s, w) => s + w, 0)
const tracks = columns.map((_, colIdx) => `minmax(0, ${(weights[colIdx] / total).toFixed(6)}fr)`)
return hasCheckbox ? `${CHECKBOX_COLUMN_WIDTH} ${tracks.join(' ')}` : tracks.join(' ')
}
interface ResourceProps {
children: ReactNode
onContextMenu?: (e: React.MouseEvent) => void
}
/**
* Compound page shell for resource pages (tables, files, knowledge, schedules,
* logs, and the detail editors). Consumers import only `Resource` and fill the
* defined slots as children:
*
* - `Resource.Header` — required, the top bar (title/breadcrumbs + action chips)
* - `Resource.Options` — required, the search/filter/sort toolbar
* - `Resource.Table` — optional; swap for any custom body (dashboard, grid, …)
*
* Invariant: the shell renders identically for every consumer. Consumers supply
* content (columns, rows, cells) and behavior (handlers, configs) only — no
* prop changes the shell's chrome, spacing, or structure. The only sanctioned
* variation is replacing `Resource.Table` with a custom body.
*
* The shell owns the fixed column layout and is the positioning context for
* absolutely-positioned overlays (action bars, slide-out sidebars); the
* children own their own chrome.
*/
function ResourceRoot({ children, onContextMenu }: ResourceProps) {
return (
<div
className='relative flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'
onContextMenu={onContextMenu}
>
{children}
</div>
)
}
/**
* Imperative handle for `Resource.Table`. Lets a consumer drive virtualizer-aware
* scrolling — required for keyboard navigation, since a `querySelector` on the
* selected row's DOM node silently no-ops once that row is windowed out.
*/
export interface ResourceTableHandle {
/** Scroll the row with the given id into view via the virtualizer (works even when the row is not in the DOM). */
scrollToRow: (rowId: string) => void
}
interface ResourceTableProps {
columns: ResourceColumn[]
rows: ResourceRow[]
selectedRowId?: string | null
/** Optional imperative handle exposing {@link ResourceTableHandle} (e.g. for keyboard-nav scrolling). */
apiRef?: RefObject<ResourceTableHandle | null>
/**
* Window the row list with `@tanstack/react-virtual`, keeping only the visible
* slice in the DOM. Opt-in because it removes off-screen rows — consumers that
* depend on every row being mounted (e.g. drag-and-drop drop targets) must stay
* on the full-DOM path. Enable it only for unbounded, accumulating lists (logs).
*/
virtualized?: boolean
selectable?: SelectableConfig
rowDragDrop?: RowDragDropConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
onLoadMore?: () => void
hasMore?: boolean
isLoadingMore?: boolean
pagination?: PaginationConfig
/**
* Sanctioned overlay slot. Rendered absolutely against the table region
* (action bars, slide-out sidebars, drop targets). The overlay owns its own
* chrome and positioning; it never alters the table's rendering.
*/
overlay?: ReactNode
}
/**
* Data table body, module-private and exposed only as `Resource.Table` — the
* compound member is the sole way consumers render it.
*
* Chrome guarantee: the table region and column headers render unconditionally —
* no prop or row state (empty, loading, error) ever drops them. Structural
* additions (checkbox column, load-more sentinel, pagination bar) are driven
* purely by which configs the consumer supplies and always render the canonical
* chrome.
*
* The table is built from `<div>`s carrying explicit ARIA roles (`table`,
* `rowgroup`, `row`, `columnheader`, `cell`) rather than native table elements:
* the rows use CSS grid for column alignment, and `display: grid` on a native
* `<table>` strips its implicit table semantics, so the roles are declared
* directly. Column widths come from a shared grid track list (see
* {@link buildGridTemplateColumns}) reproducing the legacy `<colgroup>` ratios.
* When `virtualized`, the body windows with `@tanstack/react-virtual` so only
* the visible row slice is in the DOM, bounding DOM size and memory on lists
* that accumulate many pages.
*/
const ResourceTable = memo(function ResourceTable({
columns,
rows,
selectedRowId,
apiRef,
virtualized = false,
selectable,
rowDragDrop,
onRowClick,
onRowHover,
onRowContextMenu,
onLoadMore,
hasMore,
isLoadingMore,
pagination,
overlay,
}: ResourceTableProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const loadMoreRef = useRef<HTMLDivElement>(null)
const [contextMenuRowId, setContextMenuRowId] = useState<string | null>(null)
const wrappedOnRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
setContextMenuRowId(rowId)
onRowContextMenu?.(e, rowId)
},
[onRowContextMenu]
)
useEffect(() => {
if (!contextMenuRowId) return
const clear = () => setContextMenuRowId(null)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', handleKeyDown)
clear()
}
}
const timeoutId = setTimeout(() => {
document.addEventListener('pointerdown', clear, { once: true })
document.addEventListener('keydown', handleKeyDown)
}, 0)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('pointerdown', clear)
document.removeEventListener('keydown', handleKeyDown)
}
}, [contextMenuRowId])
useEffect(() => {
if (!onLoadMore || !hasMore) return
const el = loadMoreRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onLoadMore()
},
{ rootMargin: '200px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [onLoadMore, hasMore])
const hasCheckbox = selectable != null
const handleSelectAll = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectAll(checked as boolean)
},
[selectable]
)
const gridTemplateColumns = useMemo(
() => buildGridTemplateColumns(columns, hasCheckbox),
[columns, hasCheckbox]
)
/**
* Windows the row list so only the visible slice (plus overscan) is in the
* DOM, bounding DOM size and memory regardless of how many pages a consumer
* accumulates. Rows are measured via {@link rowVirtualizer.measureElement} so
* any single-line height variance stays pixel-exact.
*/
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT_ESTIMATE,
overscan: ROW_OVERSCAN,
getItemKey: (index) => rows[index].id,
})
useImperativeHandle(
apiRef,
() => ({
scrollToRow: (rowId: string) => {
const index = rows.findIndex((row) => row.id === rowId)
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: 'auto' })
},
}),
[rows, rowVirtualizer]
)
const virtualRows = rowVirtualizer.getVirtualItems()
const totalSize = rowVirtualizer.getTotalSize()
return (
<div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto overscroll-none'>
<div role='table' className='grid w-full text-small'>
<div
role='rowgroup'
className='sticky top-0 z-10 grid bg-[var(--bg)] shadow-[inset_0_-1px_0_var(--border)]'
>
<div role='row' className='grid' style={{ gridTemplateColumns }}>
{hasCheckbox && (
<div
role='columnheader'
className='flex h-10 items-center py-1.5 pr-0 pl-5 text-left'
>
<Checkbox
size='sm'
checked={selectable.isAllSelected}
onCheckedChange={handleSelectAll}
disabled={selectable.disabled}
aria-label='Select all'
/>
</div>
)}
{columns.map((col) => (
<div
key={col.id}
role='columnheader'
className='flex h-10 min-w-0 items-center px-6 py-1.5 text-left font-normal text-[var(--text-muted)] text-small'
>
<span className='min-w-0 truncate'>{col.header}</span>
</div>
))}
</div>
</div>
<div
role='rowgroup'
className={cn('grid', virtualized && 'relative')}
style={virtualized ? { height: totalSize } : undefined}
>
{virtualized
? virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<DataRow
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
dataIndex={virtualRow.index}
translateY={virtualRow.start}
gridTemplateColumns={gridTemplateColumns}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
isContextMenuTarget={contextMenuRowId === row.id}
hasCheckbox={hasCheckbox}
/>
)
})
: rows.map((row) => (
<DataRow
key={row.id}
gridTemplateColumns={gridTemplateColumns}
row={row}
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu ? wrappedOnRowContextMenu : undefined}
isContextMenuTarget={contextMenuRowId === row.id}
hasCheckbox={hasCheckbox}
/>
))}
</div>
</div>
{hasMore && (
<div ref={loadMoreRef} className='flex items-center justify-center py-3'>
{isLoadingMore && (
<Loader className='size-[16px] text-[var(--text-secondary)]' animate />
)}
</div>
)}
</div>
{overlay}
{pagination && pagination.totalPages > 1 && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={pagination.onPageChange}
/>
)}
</div>
)
})
const Pagination = memo(function Pagination({
currentPage,
totalPages,
onPageChange,
}: {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}) {
return (
<div className='flex items-center justify-center border-[var(--border)] border-t bg-[var(--bg)] px-4 py-2.5'>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
<ChevronLeft className='size-3.5' />
</Button>
<div className='mx-3 flex items-center gap-4'>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page: number
if (totalPages <= 5) {
page = i + 1
} else if (currentPage <= 3) {
page = i + 1
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i
} else {
page = currentPage - 2 + i
}
if (page < 1 || page > totalPages) return null
return (
<Button
key={page}
type='button'
variant='ghost'
onClick={() => onPageChange(page)}
className={cn(
'h-auto p-0 font-medium text-sm transition-colors hover-hover:bg-transparent hover-hover:text-[var(--text-body)]',
page === currentPage ? 'text-[var(--text-body)]' : 'text-[var(--text-secondary)]'
)}
>
{page}
</Button>
)
})}
</div>
<Button
variant='ghost'
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
<ChevronRight className='size-3.5' />
</Button>
</div>
</div>
)
})
interface CellContentProps {
/** Pre-rendered icon node (svg/img/span avatar); auto-sized to the chip icon size. */
icon?: ReactNode
label: string
content?: ReactNode
editing?: ResourceCellEditing
}
const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) {
if (editing) {
return (
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
{icon && <span className={cellIconNodeClass}>{icon}</span>}
<InlineRenameInput
value={editing.value}
onChange={editing.onChange}
onSubmit={editing.onSubmit}
onCancel={editing.onCancel}
disabled={editing.disabled}
/>
</span>
)
}
if (content) return <>{content}</>
return (
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
{icon && <span className={cellIconNodeClass}>{icon}</span>}
<FloatingOverflowText label={label} className={cn('block', chipContentLabelClass)} />
</span>
)
})
interface DataRowProps {
row: ResourceRow
columns: ResourceColumn[]
selectedRowId?: string | null
selectable?: SelectableConfig
rowDragDrop?: RowDragDropConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
isContextMenuTarget?: boolean
hasCheckbox: boolean
/** CSS grid track list shared with the header so columns stay aligned. */
gridTemplateColumns: string
/**
* Virtual row offset. When set, the row is absolutely positioned within the
* sized tbody (windowed mode); when omitted, the row renders in normal grid
* flow (full-DOM mode).
*/
translateY?: number
/** Virtual index, consumed by the virtualizer's `measureElement` ref (windowed mode only). */
dataIndex?: number
/** Forwarded from the virtualizer so each mounted row is measured exactly (windowed mode only). */
ref?: (node: HTMLDivElement | null) => void
}
const DataRow = memo(function DataRow({
row,
columns,
selectedRowId,
selectable,
rowDragDrop,
onRowClick,
onRowHover,
onRowContextMenu,
isContextMenuTarget,
hasCheckbox,
gridTemplateColumns,
translateY,
dataIndex,
ref,
}: DataRowProps) {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false
const isDropTarget = rowDragDrop?.isRowDropTarget?.(row.id) ?? false
const isActiveDropTarget = rowDragDrop?.activeDropTargetId === row.id
const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false
const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false
const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (
selectable &&
!selectable.disabled &&
(e.shiftKey || e.metaKey || e.ctrlKey || !onRowClick || hasActiveSelection)
) {
e.preventDefault()
selectable.onSelectRow(row.id, !isSelected, e.shiftKey)
return
}
onRowClick?.(row.id)
},
[hasActiveSelection, isSelected, onRowClick, row.id, selectable]
)
const handleMouseEnter = useCallback(() => {
onRowHover?.(row.id)
}, [onRowHover, row.id])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
onRowContextMenu?.(e, row.id)
},
[onRowContextMenu, row.id]
)
const shiftKeyRef = useRef(false)
const handleSelectRowClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
shiftKeyRef.current = e.shiftKey
}, [])
const handleSelectRow = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectRow(row.id, checked as boolean, shiftKeyRef.current)
shiftKeyRef.current = false
},
[selectable, row.id]
)
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragStart?.(e, row.id)
}
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragOver?.(e, row.id)
}
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragLeave?.(e, row.id)
}
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDrop?.(e, row.id)
}
const handleDragEnd = (e: DragEvent<HTMLDivElement>) => {
rowDragDrop?.onDragEnd?.(e, row.id)
}
const isWindowed = translateY !== undefined
const rowStyle: CSSProperties = isWindowed
? { gridTemplateColumns, transform: `translateY(${translateY}px)` }
: { gridTemplateColumns }
return (
<div
ref={ref}
role='row'
data-index={dataIndex}
data-resource-row
data-row-id={row.id}
className={cn(
'grid w-full transition-colors',
isWindowed && 'absolute top-0 left-0',
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
isDraggable && 'cursor-grab active:cursor-grabbing',
isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]',
(selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]',
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]',
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
)}
style={rowStyle}
data-drop-target={isDropTarget || undefined}
draggable={isDraggable}
onClick={onRowClick || selectable ? handleClick : undefined}
onMouseEnter={handleMouseEnter}
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
onDragStart={isDraggable ? handleDragStart : undefined}
onDragOver={isDropTarget ? handleDragOver : undefined}
onDragLeave={isDropTarget ? handleDragLeave : undefined}
onDrop={isDropTarget ? handleDrop : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
{hasCheckbox && selectable && (
<div role='cell' className='flex items-center py-2.5 pr-0 pl-5'>
<Checkbox
size='sm'
checked={isSelected}
onCheckedChange={handleSelectRow}
disabled={selectable.disabled}
aria-label='Select row'
onClick={handleSelectRowClick}
/>
</div>
)}
{columns.map((col) => {
const cell = row.cells[col.id]
return (
<div key={col.id} role='cell' className='flex min-w-0 items-center px-6 py-2.5'>
<CellContent
icon={cell?.icon}
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
editing={cell?.editing}
/>
</div>
)
})}
</div>
)
})
/**
* The single public entry point. `Resource` is the layout shell; its compound
* members are the only building blocks consumers compose. Import `Resource` and
* nothing else from this module.
*/
export const Resource = Object.assign(ResourceRoot, {
Header: ResourceHeader,
Options: ResourceOptions,
Table: ResourceTable,
})