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
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:
+51
@@ -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} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ownerCell } from './owner-cell'
|
||||
+52
@@ -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,
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export type { ChromeActionSpec } from './resource-chrome-fallback'
|
||||
export { ResourceChromeFallback } from './resource-chrome-fallback'
|
||||
+91
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
BreadcrumbEditing,
|
||||
BreadcrumbItem,
|
||||
DropdownOption,
|
||||
ResourceAction,
|
||||
} from './resource-header'
|
||||
export { ResourceHeader } from './resource-header'
|
||||
+657
@@ -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
|
||||
)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
ColumnOption,
|
||||
FilterConfig,
|
||||
FilterTag,
|
||||
SearchConfig,
|
||||
SearchTag,
|
||||
SortConfig,
|
||||
} from './resource-options'
|
||||
export { ResourceOptions, SortDropdown } from './resource-options'
|
||||
+300
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user