diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts index 12dcfe6e29c..ac71fe6b97a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/index.ts @@ -3,4 +3,4 @@ export { RESOURCE_TILE_FILL, RESOURCE_TILE_PLAIN, ResourceTile, -} from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile' +} from '@sim/emcn' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx deleted file mode 100644 index e334f69116a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { ComponentType } from 'react' -import { cn } from '@sim/emcn' - -interface ResourceTileProps { - icon: ComponentType<{ className?: string }> -} - -/** - * Geometry and border of the square resource tile — the single source for that - * chrome, shared by {@link ResourceTile} and `SettingsResourceRow` so the skills, - * custom tools, and settings surfaces cannot drift apart. Pair with a fill. Sizing - * the glyph is the tile's job: the descendant rule outranks an icon's own class. - */ -export const RESOURCE_TILE_BASE = - 'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5' - -/** Filled treatment worn by the skills and custom tools resource tiles. */ -export const RESOURCE_TILE_FILL = 'bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' - -/** Page-background fill, for tiles holding a brand logo or a site favicon. */ -export const RESOURCE_TILE_PLAIN = 'bg-[var(--bg)]' - -/** - * Square glyph tile identifying a workspace resource — the leading visual on a - * resource's row and on its detail heading. - */ -export function ResourceTile({ icon: Icon }: ResourceTileProps) { - return ( -
- -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx index f1b65e5583f..108ada28855 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx @@ -71,6 +71,27 @@ vi.mock('@sim/emcn/icons', () => ({ vi.mock('@sim/emcn', () => ({ Badge: ({ children }: { children?: ReactNode }) => {children}, + ResourceRow: ({ + icon, + title, + description, + badge, + trailing, + }: { + icon?: ReactNode + title: ReactNode + description?: ReactNode + badge?: ReactNode + trailing?: ReactNode + }) => ( +
+ {icon} + {title} + {description} + {badge} + {trailing} +
+ ), Chip: ({ children, variant: _variant, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx index 93352443aa1..6f787949171 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx @@ -26,8 +26,41 @@ vi.mock('@sim/browser-protocol', () => ({ })) vi.mock('@sim/emcn', () => ({ - /** `SettingsResourceRow` composes its tile classes with `cn`. */ cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), + ResourceRow: ({ + title, + description, + icon, + trailing, + onClick, + clickLabel, + disabled, + }: { + title: ReactNode + description?: ReactNode + icon?: ReactNode + trailing?: ReactNode + onClick?: () => void + clickLabel?: string + disabled?: boolean + }) => ( +
+ {onClick && !disabled ? ( + + ) : ( + + {icon} + {title} + {description} + + )} + {trailing} +
+ ), Chip: ({ children, disabled, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx index e93a9efa44d..dc90fa828a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/components/passwords-view/passwords-view.test.tsx @@ -15,8 +15,41 @@ const { mockBridge, mockSearch, mockToast } = vi.hoisted(() => ({ })) vi.mock('@sim/emcn', () => ({ - /** `SettingsResourceRow` composes its tile classes with `cn`. */ cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), + ResourceRow: ({ + title, + description, + icon, + trailing, + onClick, + clickLabel, + disabled, + }: { + title: ReactNode + description?: ReactNode + icon?: ReactNode + trailing?: ReactNode + onClick?: () => void + clickLabel?: string + disabled?: boolean + }) => ( +
+ {onClick && !disabled ? ( + + ) : ( + + {icon} + {title} + {description} + + )} + {trailing} +
+ ), ArrowLeft: () => , ArrowRight: () => , ChipConfirmModal: ({ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts index 335ecd0a0cb..dfadf29c7c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/index.ts @@ -1,6 +1,2 @@ -export { - RESOURCE_LIST_GRID, - RESOURCE_LIST_STACK, - RESOURCE_ROW_ARROW_CLASSES, - SettingsResourceRow, -} from './settings-resource-row' +export { RESOURCE_LIST_GRID, RESOURCE_LIST_STACK, RESOURCE_ROW_ARROW_CLASSES } from '@sim/emcn' +export { SettingsResourceRow } from './settings-resource-row' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.test.tsx new file mode 100644 index 00000000000..1814ff42070 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.test.tsx @@ -0,0 +1,31 @@ +/** + * @vitest-environment jsdom + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row' + +describe('SettingsResourceRow', () => { + it('renders a real Next link beside interactive trailing controls', () => { + const html = renderToStaticMarkup( + More actions} + /> + ) + const container = document.createElement('div') + container.innerHTML = html + + const link = container.querySelector('a') + const action = container.querySelector('button') + expect(link?.getAttribute('href')).toBe('/workspace/example/knowledge') + expect(link?.getAttribute('aria-label')).toBe('Open knowledge base') + expect(link?.getAttribute('aria-describedby')).toBe(container.querySelector('span[id]')?.id) + expect(link?.contains(action ?? null)).toBe(false) + expect(action?.textContent).toBe('More actions') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index e0c7ddbc74b..73fa76dc966 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -1,255 +1,24 @@ -import { type ReactNode, useId } from 'react' -import { cn, OverflowText } from '@sim/emcn' -import { ArrowRight } from '@sim/emcn/icons' +import type { ComponentProps } from 'react' +import { ResourceRow, type ResourceRowProps } from '@sim/emcn' import Link from 'next/link' -import { - RESOURCE_TILE_BASE, - RESOURCE_TILE_FILL, - RESOURCE_TILE_PLAIN, -} from '@/app/workspace/[workspaceId]/components/resource-tile' -/** - * The canonical settings "resource row": a rounded-bordered icon tile, a - * title + muted description text block, and an optional trailing slot - * (action chips, a {@link RowActionsMenu}, a status label, etc.). - * - * Single source of truth for the credential-style row shared by the BYOK key - * manager and recently-deleted lists — never re-derive the - * tile/text chrome per consumer. The tile force-sizes any ``/`` it - * contains to 20px, so callers pass their raw icon node without pre-sizing it. - */ -interface SettingsResourceRowProps { - /** - * Icon node centered in the tile; a `` is normalized to 20px, an `` to - * 20px (or the full tile when `iconFill`). Omit it for rows whose resource has no - * identity glyph (an API key, a permission group) — the row then leads with text. - */ - icon?: ReactNode - /** - * Icon chrome. `tile` (default) is the bordered 36px tile for brand/logo and - * resource icons; `plain` drops the tile for a bare 14px glyph in - * `--text-icon`, for rows whose icon is a type marker rather than an identity - * (e.g. a folder on disk); `custom` renders `icon` verbatim, for callers that - * must supply their own tile (e.g. the brand-tinted `IntegrationTile`). - */ - iconVariant?: 'tile' | 'plain' | 'custom' - /** - * Let an image icon fill the tile edge-to-edge instead of clamping to 20px. - * Use for uploaded image/logo icons (e.g. custom blocks); glyph ``s still - * normalize to 20px so a fallback icon doesn't balloon. Tile variant only. - */ - iconFill?: boolean - /** - * Fills the tile like the skills/tools resource tiles instead of the default - * page-background tile, so a settings list can match its gallery counterpart. - */ - iconFilled?: boolean - /** Primary line — truncates. */ - title: ReactNode - /** Secondary muted line — truncates. */ - description?: ReactNode - /** - * Interactive controls pinned to the row's end (chips, actions menu). These sit - * ABOVE the row's own hit area, so their clicks are theirs. The row keeps them at - * their natural size — callers never need their own `shrink-0`. - * - * Decorative trailing content (a status badge, a tag) belongs in {@link badge}: - * anything placed here swallows clicks meant for the row. - */ - trailing?: ReactNode - /** - * Decorative trailing content — a status badge or tag. Rendered before - * {@link trailing} and made click-through, so it never turns the row's right - * edge into a dead zone. - */ - badge?: ReactNode - /** - * Makes the whole row activatable via a control with a stretched hit area. `trailing` - * stacks above it, so interactive trailing controls (menus, chips) keep - * working — never nest an interactive `trailing` inside a caller-supplied - * wrapper `
- {icon} -
- )} -
- {typeof title === 'string' ? ( - - ) : ( - {title} - )} - {description != null && - (typeof description === 'string' ? ( - - - - ) : ( - - {description} - - ))} -
- - ) - const clusterClass = cn( - 'flex min-w-0 items-center', - iconVariant === 'plain' ? 'gap-2' : 'gap-2.5' - ) - const hasEnd = badge != null || trailing != null || navigable - // Decoration and the chevron stay click-through so the row's right edge never - // becomes a dead zone; only `trailing` takes pointer events back. - const end = hasEnd ? ( -
- {badge} - {trailing != null &&
{trailing}
} - {navigable && } -
- ) : null - - // Row geometry is identical whether or not the row is activatable, so a list - // mixing clickable and static rows keeps one height and one inset. - const rowClass = cn( - 'flex items-center justify-between gap-2.5', - !flush && '-mx-2 rounded-lg p-2', - disabled && 'opacity-50' - ) - - if (disabled || (!onClick && !href)) { +export function SettingsResourceRow(props: SettingsResourceRowProps) { + if (props.href && !props.disabled) { + const href = props.href return ( -
-
{cluster}
- {end} -
+ ) => } + /> ) } - const controlClass = cn( - clusterClass, - 'min-w-0 flex-1 cursor-pointer focus-visible:outline-hidden', - 'after:absolute after:inset-0 after:rounded-lg after:content-[""]', - 'focus-visible:after:ring-2 focus-visible:after:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]' - ) - - return ( -
- {href ? ( - - {cluster} - - ) : ( - - )} - {end} -
- ) + return } diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index 975d060b136..f02c1b122c0 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -215,6 +215,21 @@ export { } from './popover/popover' export { POPOVER_ANIMATION_CLASSES } from './popover/popover-animation' export { ProgressItem } from './progress-item/progress-item' +export { + RESOURCE_LIST_GRID, + RESOURCE_LIST_STACK, + RESOURCE_ROW_ARROW_CLASSES, + ResourceRow, + type ResourceRowProps, + resourceRowIconVariants, +} from './resource-row/resource-row' +export { + RESOURCE_TILE_BASE, + RESOURCE_TILE_FILL, + RESOURCE_TILE_PLAIN, + ResourceTile, + type ResourceTileProps, +} from './resource-tile/resource-tile' export { RowActions, type RowActionsProps, rowActionsGroupClass } from './row-actions/row-actions' export { SecretInput } from './secret-input/secret-input' export { SecretReveal } from './secret-reveal/secret-reveal' diff --git a/packages/emcn/src/components/resource-row/resource-row.test.tsx b/packages/emcn/src/components/resource-row/resource-row.test.tsx new file mode 100644 index 00000000000..499a0fdd946 --- /dev/null +++ b/packages/emcn/src/components/resource-row/resource-row.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ResourceRow } from './resource-row' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(ui: ReactNode): HTMLElement { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(ui)) + return container +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('ResourceRow', () => { + it('keeps trailing controls interactive without nesting them inside the row button', () => { + const onOpen = vi.fn() + const onMenu = vi.fn() + const view = mount( + + Actions + + } + /> + ) + const rowButton = view.querySelector('button[aria-label="Open server"]') + const menuButton = view.querySelector('button:not([aria-label])') + expect(rowButton?.contains(menuButton ?? null)).toBe(false) + expect(rowButton?.getAttribute('aria-describedby')).toBe(view.querySelector('span[id]')?.id) + act(() => menuButton?.click()) + expect(onMenu).toHaveBeenCalledTimes(1) + expect(onOpen).not.toHaveBeenCalled() + act(() => rowButton?.click()) + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + it('delegates route links to a caller renderer without losing native link attributes', () => { + const renderLink = vi.fn((props: React.ComponentProps<'a'>) => ) + const view = mount( + + ) + const link = view.querySelector('a') + expect(renderLink).toHaveBeenCalledTimes(1) + expect(link?.getAttribute('href')).toBe('/files') + expect(link?.getAttribute('aria-label')).toBe('Open files') + expect(view.querySelectorAll('svg')).toHaveLength(1) + }) + + it('removes the activation target when disabled while retaining row content', () => { + const view = mount() + expect(view.querySelector('a, button')).toBeNull() + expect(view.textContent).toContain('Files') + }) +}) diff --git a/packages/emcn/src/components/resource-row/resource-row.tsx b/packages/emcn/src/components/resource-row/resource-row.tsx new file mode 100644 index 00000000000..450c5a850d1 --- /dev/null +++ b/packages/emcn/src/components/resource-row/resource-row.tsx @@ -0,0 +1,204 @@ +import { type ComponentProps, type ReactNode, useId } from 'react' +import { cva } from 'class-variance-authority' +import { ArrowRight } from '../../icons' +import { cn } from '../../lib/cn' +import { OverflowText } from '../overflow-text/overflow-text' +import { + RESOURCE_TILE_BASE, + RESOURCE_TILE_FILL, + RESOURCE_TILE_PLAIN, +} from '../resource-tile/resource-tile' + +export interface ResourceRowProps { + /** Identity glyph. Omit for resources without one. */ + icon?: ReactNode + /** Bordered tile, bare type glyph, or caller-supplied tile. */ + iconVariant?: 'tile' | 'plain' | 'custom' + /** Let image icons fill the tile; SVG glyphs keep their 20px size. */ + iconFill?: boolean + /** Use the filled resource tile instead of the page-background tile. */ + iconFilled?: boolean + title: ReactNode + description?: ReactNode + /** Interactive controls that sit above the row's stretched activation target. */ + trailing?: ReactNode + /** Click-through decorative content before the trailing controls. */ + badge?: ReactNode + onClick?: () => void + href?: string + clickLabel?: string + navigable?: boolean + /** Remove the list bleed and padding for detail headings or clipped containers. */ + flush?: boolean + disabled?: boolean + /** Render a framework link while EMCN retains the row's control classes and semantics. */ + renderLink?: (props: ComponentProps<'a'>) => ReactNode +} + +/** Chevron geometry shared with bespoke resource rows. */ +export const RESOURCE_ROW_ARROW_CLASSES = 'size-4 shrink-0 text-[var(--text-icon)]' + +/** Single-column rhythm for resource rows, whose own margins supply the bleed. */ +export const RESOURCE_LIST_STACK = 'flex flex-col gap-y-0.5' + +/** Grid gap budgets for the 8px bleed on each neighbouring row. */ +export const RESOURCE_LIST_GRID = + 'grid grid-cols-[repeat(auto-fit,minmax(264px,1fr))] gap-x-6 gap-y-0.5' + +const PLAIN_BASE = + 'flex size-[14px] shrink-0 items-center justify-center text-[var(--text-icon)] [&_svg]:size-[14px] [&_img]:size-[14px]' + +/** Wrapper treatment for tile and plain identity glyphs. */ +export const resourceRowIconVariants = cva('', { + variants: { + variant: { + tile: RESOURCE_TILE_BASE, + plain: PLAIN_BASE, + }, + }, +}) + +/** + * Resource row chrome, content, and accessible stretched activation target. + * Framework navigation is supplied through `renderLink`; without it, `href` + * renders a native anchor. + * + * @example + * } title='Files' href='/files' clickLabel='Open files' navigable /> + */ +export function ResourceRow({ + icon, + iconVariant = 'tile', + iconFill = false, + iconFilled = false, + title, + description, + trailing, + badge, + onClick, + href, + clickLabel, + navigable = false, + flush = false, + disabled = false, + renderLink, +}: ResourceRowProps) { + const describedById = useId() + const isTile = iconVariant === 'tile' + const isActivatable = !disabled && Boolean(onClick || href) + const cluster = ( + <> + {icon == null ? null : iconVariant === 'custom' ? ( + icon + ) : ( +
+ {icon} +
+ )} +
+ {typeof title === 'string' ? ( + + ) : ( + {title} + )} + {description != null && + (typeof description === 'string' ? ( + + + + ) : ( + + {description} + + ))} +
+ + ) + const clusterClass = cn( + 'flex min-w-0 items-center', + iconVariant === 'plain' ? 'gap-2' : 'gap-2.5' + ) + const hasEnd = badge != null || trailing != null || navigable + const end = hasEnd ? ( +
+ {badge} + {trailing != null &&
{trailing}
} + {navigable && } +
+ ) : null + + const rowClass = cn( + 'flex items-center justify-between gap-2.5', + !flush && '-mx-2 rounded-lg p-2', + disabled && 'opacity-50' + ) + + if (disabled || (!onClick && !href)) { + return ( +
+
{cluster}
+ {end} +
+ ) + } + + const controlClass = cn( + clusterClass, + 'min-w-0 flex-1 cursor-pointer focus-visible:outline-hidden', + 'after:absolute after:inset-0 after:rounded-lg after:content-[""]', + 'focus-visible:after:ring-2 focus-visible:after:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]' + ) + + const linkProps: ComponentProps<'a'> = { + href, + 'aria-label': clickLabel, + 'aria-describedby': description != null ? describedById : undefined, + className: controlClass, + children: cluster, + } + + return ( +
+ ) +} diff --git a/packages/emcn/src/components/resource-tile/resource-tile.tsx b/packages/emcn/src/components/resource-tile/resource-tile.tsx new file mode 100644 index 00000000000..3a573b2e41d --- /dev/null +++ b/packages/emcn/src/components/resource-tile/resource-tile.tsx @@ -0,0 +1,25 @@ +import type { ComponentType } from 'react' +import { cn } from '../../lib/cn' + +export interface ResourceTileProps { + icon: ComponentType<{ className?: string }> +} + +/** Shared geometry for resource identity tiles in lists and detail headings. */ +export const RESOURCE_TILE_BASE = + 'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5' + +/** Filled tile for product resources such as skills and tools. */ +export const RESOURCE_TILE_FILL = 'bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' + +/** Page-background tile for brand marks and favicons. */ +export const RESOURCE_TILE_PLAIN = 'bg-[var(--bg)]' + +/** Square glyph tile identifying a resource. */ +export function ResourceTile({ icon: Icon }: ResourceTileProps) { + return ( +
+ +
+ ) +}