mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
improvement(ui): standardize overflow text (#7175)
* improvement(ui): standardize overflow text * fix(ui): remeasure overflow text changes * fix(ui): refresh overflow after font loads * fix(ui): stabilize overflow font test * fix(ui): preserve overflow text interactions * fix(test): update overflow component mocks * fix(ui): keep overflow tooltips in sync * fix(ui): preserve overflow server boundary * test(ui): cover strict mode overflow replay * fix(ui): restore overflow observers on effect replay * fix(ui): harden overflow measurement defaults
This commit is contained in:
@@ -32,6 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
|
||||
- **`ChipDatePicker`** — chip-styled date field.
|
||||
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
|
||||
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
|
||||
- **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, single-line clipping, the conditional 18px edge fade, and the full-value floating tooltip; consumers pass only layout/typography through `className`. Never combine the fade with `truncate`, which paints an ellipsis beneath the mask. Keep ordinary `truncate` for editable or mirrored input values, code/log/path content, dense or virtualized grids, and composite rows where masking the container would also fade icons or actions. Multiline copy uses an intentional `line-clamp-*` treatment instead. A non-editable `Combobox` visual overlay passes its plain value through `overlayLabel`; render its visible `OverflowText` as a constrained block with `tooltipEnabled={false}` so the interactive trigger owns the single accessible tooltip.
|
||||
|
||||
## Modal keyboard defaults
|
||||
|
||||
|
||||
@@ -50,6 +50,14 @@ Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`=
|
||||
|
||||
Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`.
|
||||
|
||||
## Text Overflow
|
||||
|
||||
Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, single-line clipping, the conditional edge fade, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`, which leaves an ellipsis beneath the mask. Pass the full label to this component instead of shortening it in JavaScript first.
|
||||
|
||||
For a non-editable `Combobox` visual overlay, pass the same plain value as `overlayLabel` and render the visible `OverflowText` with `block w-full` (or `block flex-1` beside an icon) plus `tooltipEnabled={false}`. The transparent interactive layer then owns the one reachable full-value tooltip while the visual layer owns the measured fade.
|
||||
|
||||
Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment.
|
||||
|
||||
## Font Weight
|
||||
|
||||
Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body.
|
||||
|
||||
@@ -44,6 +44,14 @@ Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`=
|
||||
|
||||
Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`.
|
||||
|
||||
## Text Overflow
|
||||
|
||||
Use `OverflowText` from `@sim/emcn` for a constrained, single-line, read-only human label or title. It owns `min-w-0`, single-line clipping, the conditional edge fade, and the full-value floating tooltip; pass only layout and typography through `className`. Never combine a fade or hand-written `mask-image` with `truncate`, which leaves an ellipsis beneath the mask. Pass the full label to this component instead of shortening it in JavaScript first.
|
||||
|
||||
For a non-editable `Combobox` visual overlay, pass the same plain value as `overlayLabel` and render the visible `OverflowText` with `block w-full` (or `block flex-1` beside an icon) plus `tooltipEnabled={false}`. The transparent interactive layer then owns the one reachable full-value tooltip while the visual layer owns the measured fade.
|
||||
|
||||
Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment.
|
||||
|
||||
## Color Tokens
|
||||
|
||||
Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; borders `--border-1` (fields) / `--border` (dividers); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Chip } from '@sim/emcn'
|
||||
import { Chip, OverflowText } from '@sim/emcn'
|
||||
import { Download } from '@sim/emcn/icons'
|
||||
import Link from 'next/link'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
@@ -85,7 +85,7 @@ export function PublicFileView({
|
||||
</>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='truncate text-[14px] text-[var(--text-body)]'>{name}</span>
|
||||
<OverflowText label={name} className='text-[var(--text-body)] text-sm' />
|
||||
{provenance ? (
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>{provenance}</span>
|
||||
) : null}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { cn, OverflowText } from '@sim/emcn'
|
||||
|
||||
interface ConversationListItemProps {
|
||||
title: string
|
||||
@@ -23,7 +23,7 @@ export function ConversationListItem({
|
||||
const showStatusDot = isActive || isUnread
|
||||
return (
|
||||
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
|
||||
<span className={cn('min-w-0 flex-1 truncate', titleClassName)}>{title}</span>
|
||||
<OverflowText label={title} className={cn('flex-1', titleClassName)} />
|
||||
{showStatusDot && (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
|
||||
+1
-49
@@ -1,51 +1,3 @@
|
||||
'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} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
export { OverflowText as FloatingOverflowText } from '@sim/emcn'
|
||||
|
||||
+5
-2
@@ -24,6 +24,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FloatingTooltip,
|
||||
OverflowText,
|
||||
overflowTextFadeClass,
|
||||
POPOVER_ANIMATION_CLASSES,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -664,7 +666,7 @@ function BreadcrumbLocationItem({
|
||||
<span className='size-1.5 rounded-full bg-[var(--text-muted)]' />
|
||||
)}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
|
||||
<OverflowText label={label} className='flex-1 text-left' />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -702,8 +704,9 @@ const BreadcrumbLabel = memo(
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'min-w-0 truncate text-[var(--text-body)]',
|
||||
isOverflowing && overflowTextFadeClass,
|
||||
isOverflowing &&
|
||||
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] group-hover:[mask-image:none] group-focus-visible:[mask-image:none]'
|
||||
'group-hover:[-webkit-mask-image:none] group-hover:[mask-image:none] group-focus-visible:[-webkit-mask-image:none] group-focus-visible:[mask-image:none]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
+3
-2
@@ -35,7 +35,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('SortDropdown', () => {
|
||||
it('renders long option labels in the truncatable span between both icons', () => {
|
||||
it('renders long option labels in the overflow span between both icons', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SortDropdown
|
||||
@@ -63,7 +63,8 @@ describe('SortDropdown', () => {
|
||||
const label = item?.querySelector('span')
|
||||
expect(label).not.toBeNull()
|
||||
expect(label).toHaveTextContent(LONG_COLUMN_LABEL)
|
||||
expect(label).toHaveClass('min-w-0', 'block', 'truncate')
|
||||
expect(label).toHaveClass('min-w-0', 'block', 'overflow-hidden', 'text-clip')
|
||||
expect(label).not.toHaveClass('truncate')
|
||||
expect(item?.querySelector('[data-testid="column-icon"]')).not.toBeNull()
|
||||
expect(item?.querySelectorAll('svg')).toHaveLength(2)
|
||||
})
|
||||
|
||||
+2
-2
@@ -232,7 +232,7 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
|
||||
active={search.highlightedTagIndex === i}
|
||||
className='max-w-[280px] shrink-0'
|
||||
>
|
||||
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block truncate'>
|
||||
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block'>
|
||||
{tag.label}: {tag.value}
|
||||
</FloatingOverflowText>
|
||||
</Chip>
|
||||
@@ -330,7 +330,7 @@ export const SortDropdown = memo(function SortDropdown({
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon />}
|
||||
<FloatingOverflowText label={option.label} className='block truncate' />
|
||||
<FloatingOverflowText label={option.label} className='block' />
|
||||
{DirectionIcon && (
|
||||
<DirectionIcon className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Loader,
|
||||
OverflowText,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash,
|
||||
@@ -1968,8 +1969,13 @@ export function Files() {
|
||||
multiSelect
|
||||
multiSelectValues={typeFilter}
|
||||
onMultiSelectChange={setTypeFilter}
|
||||
overlayLabel={typeDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{typeDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={typeDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
@@ -1987,8 +1993,13 @@ export function Files() {
|
||||
multiSelect
|
||||
multiSelectValues={sizeFilter}
|
||||
onMultiSelectChange={setSizeFilter}
|
||||
overlayLabel={sizeDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{sizeDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={sizeDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
|
||||
import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn'
|
||||
import { ShimmerText } from '@/components/ui'
|
||||
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
|
||||
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
|
||||
@@ -209,7 +209,7 @@ export function AgentGroup({
|
||||
{isWorking ? (
|
||||
<ShimmerText className='min-w-0 truncate text-sm'>{headerText}</ShimmerText>
|
||||
) : (
|
||||
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>{headerText}</span>
|
||||
<OverflowText label={headerText} className='text-[var(--text-body)] text-sm' />
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
@@ -226,7 +226,7 @@ export function AgentGroup({
|
||||
{isWorking ? (
|
||||
<ShimmerText className='min-w-0 truncate text-sm'>{headerText}</ShimmerText>
|
||||
) : (
|
||||
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>{headerText}</span>
|
||||
<OverflowText label={headerText} className='text-[var(--text-body)] text-sm' />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn'
|
||||
import { Button, OverflowText, PlayOutline, Skeleton, Tooltip, toast } from '@sim/emcn'
|
||||
import {
|
||||
Download,
|
||||
FileX,
|
||||
@@ -782,7 +782,7 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
|
||||
className='flex items-center gap-2 rounded-[6px] px-3 py-2 text-left transition-colors hover:bg-[var(--surface-4)]'
|
||||
>
|
||||
<WorkflowIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate text-[13px] text-[var(--text-primary)]'>{w.name}</span>
|
||||
<OverflowText label={w.name} className='text-[var(--text-primary)] text-small' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ElementType, ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { cn, OverflowText } from '@sim/emcn'
|
||||
import {
|
||||
Connections,
|
||||
Database,
|
||||
@@ -54,13 +54,13 @@ function WorkflowDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
return (
|
||||
<>
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
<OverflowText label={item.name} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
return <span className='truncate'>{item.name}</span>
|
||||
return <OverflowText label={item.name} />
|
||||
}
|
||||
|
||||
function FileDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
@@ -68,7 +68,7 @@ function FileDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
return (
|
||||
<>
|
||||
<DocIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
<OverflowText label={item.name} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon
|
||||
return (
|
||||
<>
|
||||
<Icon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
<OverflowText label={item.name} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -90,11 +90,11 @@ function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon
|
||||
*/
|
||||
function IntegrationDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
const Icon = item.iconComponent as StyleableIcon | undefined
|
||||
if (!Icon) return <span className='truncate'>{item.name}</span>
|
||||
if (!Icon) return <OverflowText label={item.name} />
|
||||
return (
|
||||
<>
|
||||
<BrandIcon icon={Icon} className='size-[14px] flex-shrink-0' />
|
||||
<span className='truncate'>{item.name}</span>
|
||||
<OverflowText label={item.name} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ function LogDropdownItem({ item }: DropdownItemRenderProps) {
|
||||
return (
|
||||
<>
|
||||
<Library className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate'>{workflowName}</span>
|
||||
<OverflowText label={workflowName} />
|
||||
{statusColor && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
|
||||
import { Badge, ChipCombobox, ChipConfirmModal, chipContentLabelClass, cn } from '@sim/emcn'
|
||||
import {
|
||||
Badge,
|
||||
ChipCombobox,
|
||||
ChipConfirmModal,
|
||||
chipContentLabelClass,
|
||||
cn,
|
||||
OverflowText,
|
||||
} from '@sim/emcn'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -746,8 +753,13 @@ export function Document({
|
||||
setEnabledFilter(values)
|
||||
setSelectedChunks(new Set())
|
||||
}}
|
||||
overlayLabel={enabledDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{enabledDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={enabledDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
|
||||
+6
-2
@@ -18,6 +18,7 @@ import {
|
||||
type ComboboxOption,
|
||||
cn,
|
||||
handleKeyboardActivation,
|
||||
OverflowText,
|
||||
Search,
|
||||
} from '@sim/emcn'
|
||||
import { ArrowLeft, Plus } from '@sim/emcn/icons'
|
||||
@@ -508,8 +509,11 @@ function ConnectorTypeCard({ type, config, onClick }: ConnectorTypeCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='truncate text-[var(--text-body)] text-sm'>{config.name}</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>{config.description}</span>
|
||||
<OverflowText label={config.name} className='text-[var(--text-body)] text-sm' />
|
||||
<OverflowText
|
||||
label={config.description}
|
||||
className='text-[var(--text-muted)] text-caption'
|
||||
/>
|
||||
</div>
|
||||
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
|
||||
+3
@@ -60,6 +60,9 @@ vi.mock('@sim/emcn', () => ({
|
||||
DropdownMenuContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => (
|
||||
<span>{children ?? label}</span>
|
||||
),
|
||||
Tooltip: {
|
||||
Root: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
Trigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
OverflowText,
|
||||
Tooltip,
|
||||
} from '@sim/emcn'
|
||||
import {
|
||||
@@ -362,7 +363,7 @@ function ConnectorCard({
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<span className='flex min-w-0 items-center gap-1.5 text-[var(--text-primary)] text-small'>
|
||||
<span className='truncate'>{connectorDef?.name || connector.connectorType}</span>
|
||||
<OverflowText label={connectorDef?.name || connector.connectorType} />
|
||||
{syncInFlight && <Loader className='size-3 text-[var(--text-muted)]' animate />}
|
||||
</span>
|
||||
<Badge variant={statusConfig.variant} size='sm' dot className='flex-shrink-0'>
|
||||
|
||||
@@ -401,7 +401,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
||||
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
|
||||
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>Runs</span>
|
||||
<span className='min-w-0 text-[var(--text-primary)] text-sm'>Runs</span>
|
||||
{globalDetails && (
|
||||
<span className='flex-shrink-0 text-[var(--text-secondary)] text-sm'>
|
||||
{globalDetails.totalRuns}
|
||||
@@ -426,7 +426,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
|
||||
|
||||
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
|
||||
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>Errors</span>
|
||||
<span className='min-w-0 text-[var(--text-primary)] text-sm'>Errors</span>
|
||||
{globalDetails && (
|
||||
<span className='flex-shrink-0 text-[var(--text-secondary)] text-sm'>
|
||||
{globalDetails.totalErrors}
|
||||
@@ -451,7 +451,7 @@ function DashboardInner({ stats, isLoading, error, searchQuery }: DashboardProps
|
||||
|
||||
<div className='flex flex-col overflow-hidden rounded-md bg-[var(--surface-2)] dark:bg-[var(--surface-2)]'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-2 bg-[var(--surface-3)] px-4 py-[9px] dark:bg-[var(--surface-3)]'>
|
||||
<span className='min-w-0 truncate text-[var(--text-primary)] text-sm'>Latency</span>
|
||||
<span className='min-w-0 text-[var(--text-primary)] text-sm'>Latency</span>
|
||||
{globalDetails && (
|
||||
<span className='flex-shrink-0 text-[var(--text-secondary)] text-sm'>
|
||||
{formatLatency(globalDetails.avgLatency)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type ComboboxOption,
|
||||
cn,
|
||||
Library,
|
||||
OverflowText,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
@@ -1463,15 +1464,20 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
multiSelectValues={selectedStatuses}
|
||||
onMultiSelectChange={handleStatusChange}
|
||||
placeholder='All statuses'
|
||||
overlayLabel={statusDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
<span className='flex w-full min-w-0 items-center gap-1.5 text-[var(--text-primary)]'>
|
||||
{selectedStatusColor && (
|
||||
<div
|
||||
className='flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: selectedStatusColor, width: 8, height: 8 }}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{statusDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={statusDisplayLabel}
|
||||
className='block flex-1'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
showAllOption
|
||||
@@ -1488,12 +1494,17 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
multiSelectValues={workflowIds}
|
||||
onMultiSelectChange={setWorkflowIds}
|
||||
placeholder='All workflows'
|
||||
overlayLabel={workflowDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
<span className='flex w-full min-w-0 items-center gap-1.5 text-[var(--text-primary)]'>
|
||||
{selectedWorkflow && (
|
||||
<Workflow className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
)}
|
||||
<span className='truncate'>{workflowDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={workflowDisplayLabel}
|
||||
className='block flex-1'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
searchable
|
||||
@@ -1512,8 +1523,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
multiSelectValues={folderIds}
|
||||
onMultiSelectChange={setFolderIds}
|
||||
placeholder='All folders'
|
||||
overlayLabel={folderDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={folderDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search folders...'
|
||||
@@ -1531,8 +1547,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
multiSelectValues={triggers}
|
||||
onMultiSelectChange={setTriggers}
|
||||
placeholder='All triggers'
|
||||
overlayLabel={triggerDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={triggerDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search triggers...'
|
||||
@@ -1550,8 +1571,13 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
placeholder='All time'
|
||||
overlayLabel={timeDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={timeDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
className='w-full'
|
||||
maxHeight={320}
|
||||
@@ -1627,7 +1653,7 @@ function SuggestionButton({
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1 truncate text-small'>{suggestion.label}</div>
|
||||
<OverflowText label={suggestion.label} className='flex-1 text-small' />
|
||||
{showCategory && suggestion.value !== suggestion.label && (
|
||||
<div className='shrink-0 font-mono text-[var(--text-muted)] text-xs'>
|
||||
{suggestion.category === 'workflow' || suggestion.category === 'folder'
|
||||
|
||||
+2
-2
@@ -65,14 +65,14 @@ function ActivityLogRow({
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 text-[var(--text-primary)] text-small'>
|
||||
{typeof entry.description === 'string' ? (
|
||||
<FloatingOverflowText label={entry.description} className='block truncate' />
|
||||
<FloatingOverflowText label={entry.description} className='block' />
|
||||
) : (
|
||||
entry.description
|
||||
)}
|
||||
</span>
|
||||
<span className='flex w-[160px] flex-shrink-0 items-center justify-end gap-1.5 text-[var(--text-secondary)] text-small'>
|
||||
{typeof entry.actor === 'string' ? (
|
||||
<FloatingOverflowText label={entry.actor} className='block min-w-0 truncate' />
|
||||
<FloatingOverflowText label={entry.actor} className='block' />
|
||||
) : (
|
||||
<span className='min-w-0 truncate'>{entry.actor}</span>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChipModalField,
|
||||
ChipSelect,
|
||||
Label,
|
||||
OverflowText,
|
||||
Search,
|
||||
Switch,
|
||||
toast,
|
||||
@@ -219,8 +220,8 @@ export function Admin() {
|
||||
|
||||
const renderUserRow = (u: AdminUser) => (
|
||||
<div key={u.id} className='flex items-center gap-3 px-3 py-2 text-small'>
|
||||
<span className='w-[170px] truncate text-[var(--text-primary)]'>{u.name || '—'}</span>
|
||||
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
|
||||
<OverflowText label={u.name || '—'} className='w-[170px] text-[var(--text-primary)]' />
|
||||
<OverflowText label={u.email} className='flex-1 text-[var(--text-secondary)]' />
|
||||
<span className='w-[60px]'>
|
||||
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
|
||||
</span>
|
||||
|
||||
@@ -48,6 +48,9 @@ vi.mock('@sim/emcn', () => ({
|
||||
Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
|
||||
<label htmlFor={htmlFor}>{children}</label>
|
||||
),
|
||||
OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => (
|
||||
<span>{children ?? label}</span>
|
||||
),
|
||||
Switch: ({
|
||||
checked,
|
||||
disabled,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
chipVariants,
|
||||
cn,
|
||||
Label,
|
||||
OverflowText,
|
||||
Switch,
|
||||
Tooltip,
|
||||
toast,
|
||||
@@ -480,8 +481,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='truncate text-[var(--text-body)] text-sm'>{planTitle}</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>{priceText}</span>
|
||||
<OverflowText label={planTitle} className='text-[var(--text-body)] text-sm' />
|
||||
<OverflowText label={priceText} className='text-[var(--text-muted)] text-caption' />
|
||||
</div>
|
||||
</div>
|
||||
{!subscription.isEnterprise &&
|
||||
|
||||
+3
@@ -34,6 +34,9 @@ vi.mock('@sim/emcn', () => ({
|
||||
</div>
|
||||
) : null,
|
||||
Key: () => <span />,
|
||||
OverflowText: ({ label, children }: { label: string; children?: ReactNode }) => (
|
||||
<span>{children ?? label}</span>
|
||||
),
|
||||
Plus: () => <span />,
|
||||
toast: mockToast,
|
||||
}))
|
||||
|
||||
+16
-7
@@ -1,6 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader } from '@sim/emcn'
|
||||
import {
|
||||
Chip,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
OverflowText,
|
||||
} from '@sim/emcn'
|
||||
import type {
|
||||
BYOKManagerCapabilities,
|
||||
BYOKManagerKey,
|
||||
@@ -53,12 +60,14 @@ export function BYOKProviderKeysModal({
|
||||
{keys.map((key) => (
|
||||
<div key={key.id} className='flex items-center justify-between gap-2.5'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<span className='truncate text-[var(--text-body)] text-sm'>
|
||||
{key.name ?? 'Unnamed key'}
|
||||
</span>
|
||||
<span className='truncate font-mono text-[var(--text-muted)] text-caption'>
|
||||
{key.maskedKey}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={key.name ?? 'Unnamed key'}
|
||||
className='text-[var(--text-body)] text-sm'
|
||||
/>
|
||||
<OverflowText
|
||||
label={key.maskedKey}
|
||||
className='font-mono text-[var(--text-muted)] text-caption'
|
||||
/>
|
||||
</div>
|
||||
{(capabilities.update || capabilities.delete) && (
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { OverflowText } from '@sim/emcn'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
|
||||
const ROW_CLASSES = 'flex items-center gap-2.5 p-2'
|
||||
const ROW_EMAIL_CLASSES = 'min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'
|
||||
const ROW_STATUS_CLASSES = 'flex-shrink-0 text-[var(--text-muted)] text-caption'
|
||||
|
||||
interface MemberAvatarProps {
|
||||
@@ -57,7 +57,7 @@ export function MemberRow({ name, email, image, status, roleControl, menu }: Mem
|
||||
return (
|
||||
<div className={ROW_CLASSES}>
|
||||
<MemberAvatar name={name} image={image} />
|
||||
<span className={ROW_EMAIL_CLASSES}>{email}</span>
|
||||
<OverflowText label={email} className='flex-1 text-[var(--text-body)] text-sm' />
|
||||
<span className={ROW_STATUS_CLASSES}>{status}</span>
|
||||
{roleControl}
|
||||
{menu}
|
||||
|
||||
+40
-21
@@ -1,5 +1,5 @@
|
||||
import { type ReactNode, useId } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { cn, OverflowText } from '@sim/emcn'
|
||||
import { ArrowRight } from '@sim/emcn/icons'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
@@ -64,7 +64,7 @@ interface SettingsResourceRowProps {
|
||||
*/
|
||||
badge?: ReactNode
|
||||
/**
|
||||
* Makes the whole row activatable via a stretched overlay button. `trailing`
|
||||
* 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 `<button>`, which is invalid HTML.
|
||||
@@ -137,6 +137,7 @@ export function SettingsResourceRow({
|
||||
}: SettingsResourceRowProps) {
|
||||
const describedById = useId()
|
||||
const isTile = iconVariant === 'tile'
|
||||
const isActivatable = !disabled && Boolean(onClick || href)
|
||||
const cluster = (
|
||||
<>
|
||||
{icon == null ? null : iconVariant === 'custom' ? (
|
||||
@@ -156,13 +157,29 @@ export function SettingsResourceRow({
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px] text-left'>
|
||||
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
|
||||
{description != null && (
|
||||
<span id={describedById} className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{description}
|
||||
</span>
|
||||
<div className='relative z-10 flex min-w-0 flex-col justify-center gap-[1px] text-left'>
|
||||
{typeof title === 'string' ? (
|
||||
<OverflowText
|
||||
label={title}
|
||||
className='text-[var(--text-body)] text-sm'
|
||||
focusTarget={isActivatable ? 'nearest-interactive' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
|
||||
)}
|
||||
{description != null &&
|
||||
(typeof description === 'string' ? (
|
||||
<span id={describedById} className='min-w-0'>
|
||||
<OverflowText
|
||||
label={description}
|
||||
className='block text-[var(--text-muted)] text-caption'
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span id={describedById} className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{description}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -174,7 +191,7 @@ export function SettingsResourceRow({
|
||||
// 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 ? (
|
||||
<div className='pointer-events-none relative flex flex-shrink-0 items-center gap-2'>
|
||||
<div className='pointer-events-none relative z-20 flex flex-shrink-0 items-center gap-2'>
|
||||
{badge}
|
||||
{trailing != null && <div className='pointer-events-auto flex items-center'>{trailing}</div>}
|
||||
{navigable && <ArrowRight className={RESOURCE_ROW_ARROW_CLASSES} />}
|
||||
@@ -198,14 +215,13 @@ export function SettingsResourceRow({
|
||||
)
|
||||
}
|
||||
|
||||
// The ring renders on the stretched overlay, which is inset-0 over the row — so a
|
||||
// keyboard focus outline traces the visible row even though the control is empty.
|
||||
const overlayClass =
|
||||
'absolute inset-0 cursor-pointer rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]'
|
||||
const controlClass = cn(
|
||||
clusterClass,
|
||||
'min-w-0 flex-1 cursor-pointer focus-visible:outline-none',
|
||||
'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)]'
|
||||
)
|
||||
|
||||
// The hit area is a stretched overlay rather than a wrapper around the cluster:
|
||||
// it lets the hover band span the full row (matching every hand-rolled settings
|
||||
// list) while `trailing` — which may hold its own buttons — stacks above it.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -218,18 +234,21 @@ export function SettingsResourceRow({
|
||||
href={href}
|
||||
aria-label={clickLabel}
|
||||
aria-describedby={description != null ? describedById : undefined}
|
||||
className={overlayClass}
|
||||
/>
|
||||
className={controlClass}
|
||||
>
|
||||
{cluster}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
aria-label={clickLabel}
|
||||
aria-describedby={description != null ? describedById : undefined}
|
||||
className={overlayClass}
|
||||
/>
|
||||
className={controlClass}
|
||||
>
|
||||
{cluster}
|
||||
</button>
|
||||
)}
|
||||
<div className={cn(clusterClass, 'pointer-events-none')}>{cluster}</div>
|
||||
{end}
|
||||
</div>
|
||||
)
|
||||
|
||||
+9
-6
@@ -10,6 +10,7 @@ import {
|
||||
ChipConfirmModal,
|
||||
ChipInput,
|
||||
cn,
|
||||
OverflowText,
|
||||
Search,
|
||||
Skeleton,
|
||||
} from '@sim/emcn'
|
||||
@@ -182,18 +183,20 @@ export function TransferOwnershipDialog({
|
||||
</Avatar>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='truncate text-[var(--text-primary)] text-small'>
|
||||
{m.name}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={m.name}
|
||||
className='text-[var(--text-primary)] text-small'
|
||||
/>
|
||||
{m.role === 'admin' && (
|
||||
<Badge variant='gray-secondary' size='sm'>
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{m.email}
|
||||
</div>
|
||||
<OverflowText
|
||||
label={m.email}
|
||||
className='block text-[var(--text-muted)] text-caption'
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export function SecretUsagePanel({ workspaceId, secretName, scope }: SecretUsage
|
||||
*/
|
||||
description: (
|
||||
<span className='flex min-w-0 items-center gap-2'>
|
||||
<FloatingOverflowText label={usedBy(entry)} className='block truncate' />
|
||||
<FloatingOverflowText label={usedBy(entry)} className='block' />
|
||||
{entry.useCount > 1 && (
|
||||
<span className='flex-shrink-0 text-[var(--text-muted)]'>{entry.useCount} runs</span>
|
||||
)}
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import {
|
||||
CollapsibleCard,
|
||||
FieldDivider,
|
||||
Label,
|
||||
OverflowText,
|
||||
Switch,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
@@ -242,7 +243,7 @@ export function EnrichmentConfig({
|
||||
>
|
||||
<ArrowLeft className='size-[14px]' />
|
||||
</Button>
|
||||
<h2 className='truncate text-[var(--text-primary)] text-small'>{enrichment.name}</h2>
|
||||
<OverflowText label={enrichment.name} className='text-[var(--text-primary)] text-small' />
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Badge, cn } from '@sim/emcn'
|
||||
import { Badge, cn, OverflowText } from '@sim/emcn'
|
||||
import type { ColumnDefinition, SelectOption } from '@/lib/table'
|
||||
|
||||
/** Reads the raw stored option ids from a cell value (single string or array). */
|
||||
@@ -38,7 +38,7 @@ interface SelectPillProps {
|
||||
export function SelectPill({ option, size = 'sm', className }: SelectPillProps) {
|
||||
return (
|
||||
<Badge variant='gray' size={size} className={cn('max-w-full', className)}>
|
||||
<span className='truncate'>{option.name}</span>
|
||||
<OverflowText label={option.name} />
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import {
|
||||
chipContentLabelClass,
|
||||
chipVariants,
|
||||
cn,
|
||||
OverflowText,
|
||||
POPOVER_ANIMATION_CLASSES,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -197,7 +198,7 @@ export const ViewsMenu = memo(function ViewsMenu({
|
||||
<span className='flex size-[14px] shrink-0 items-center justify-center'>
|
||||
<Plus className='size-3 text-[var(--text-icon)]' />
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate text-left'>New view</span>
|
||||
<OverflowText label='New view' className='flex-1 text-left' />
|
||||
</PopoverItem>
|
||||
</>
|
||||
)}
|
||||
@@ -268,7 +269,7 @@ function ViewRow({ label, isActive, onSelect, defaultState, actions }: ViewRowPr
|
||||
<span className='flex size-[14px] shrink-0 items-center justify-center'>
|
||||
{isActive && <Check className='size-3 text-[var(--text-icon)]' />}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
|
||||
<OverflowText label={label} className='flex-1 text-left' />
|
||||
{actionCount > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
|
||||
+4
-1
@@ -13,6 +13,7 @@ import {
|
||||
FieldDivider,
|
||||
Label,
|
||||
Loader,
|
||||
OverflowText,
|
||||
Switch,
|
||||
Tooltip,
|
||||
toast,
|
||||
@@ -763,7 +764,9 @@ export function WorkflowSidebarBody({
|
||||
<ArrowLeft className='size-[14px]' />
|
||||
</Button>
|
||||
)}
|
||||
<h2 className='truncate text-[var(--text-primary)] text-small'>{title}</h2>
|
||||
<h2 className='flex min-w-0'>
|
||||
<OverflowText label={title} className='text-[var(--text-primary)] text-small' />
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ComboboxOption } from '@sim/emcn'
|
||||
import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn'
|
||||
import { ChipCombobox, ChipConfirmModal, OverflowText, Plus, toast, Upload } from '@sim/emcn'
|
||||
import { Columns3, FolderPlus, Pencil, Rows3, Table as TableIcon, Trash } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
@@ -692,8 +692,13 @@ export function Tables() {
|
||||
multiSelect
|
||||
multiSelectValues={rowCountFilter}
|
||||
onMultiSelectChange={setRowCountFilter}
|
||||
overlayLabel={rowCountDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{rowCountDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={rowCountDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
showAllOption
|
||||
allOptionLabel='All'
|
||||
@@ -708,8 +713,13 @@ export function Tables() {
|
||||
multiSelect
|
||||
multiSelectValues={ownerFilter}
|
||||
onMultiSelectChange={setOwnerFilter}
|
||||
overlayLabel={ownerDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={ownerDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
searchable
|
||||
searchPlaceholder='Search members...'
|
||||
|
||||
+7
-2
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn'
|
||||
import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn, OverflowText } from '@sim/emcn'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
type FlattenOutputsBlockInput,
|
||||
@@ -269,8 +269,13 @@ export function OutputSelect({
|
||||
multiSelectValues={normalizedSelectedValues}
|
||||
onMultiSelectChange={onOutputSelect}
|
||||
placeholder={selectedDisplayText}
|
||||
overlayLabel={selectedDisplayText}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{selectedDisplayText}</span>
|
||||
<OverflowText
|
||||
label={selectedDisplayText}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
disabled={disabled || workflowOutputs.length === 0}
|
||||
align={align}
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import {
|
||||
Button,
|
||||
cn,
|
||||
Input,
|
||||
OverflowText,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
@@ -296,7 +297,7 @@ export function Versions({
|
||||
<span className='shrink-0 text-[var(--text-tertiary)] tabular-nums'>
|
||||
v{v.version}
|
||||
</span>
|
||||
{v.name && <span className='truncate'>{v.name}</span>}
|
||||
{v.name && <OverflowText label={v.name} />}
|
||||
{rowLabel && (
|
||||
<span className='shrink-0 text-[var(--text-tertiary)]'>({rowLabel})</span>
|
||||
)}
|
||||
|
||||
+12
-8
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
cn,
|
||||
OverflowText,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
@@ -442,7 +443,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{currentNestedTag.display}</span>
|
||||
<OverflowText label={currentNestedTag.display} className='flex-1' />
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
@@ -482,7 +483,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{child.display}</span>
|
||||
<OverflowText label={child.display} className='flex-1' />
|
||||
{childType && childType !== 'any' && (
|
||||
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>
|
||||
{childType}
|
||||
@@ -518,7 +519,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{nestedChild.display}</span>
|
||||
<OverflowText label={nestedChild.display} className='flex-1' />
|
||||
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>{'>'}</span>
|
||||
</PopoverItem>
|
||||
)
|
||||
@@ -673,7 +674,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{nestedTag.display}</span>
|
||||
<OverflowText label={nestedTag.display} className='flex-1' />
|
||||
{tagDescription && tagDescription !== 'any' && (
|
||||
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>
|
||||
{tagDescription}
|
||||
@@ -741,9 +742,12 @@ const VariableTagItem: React.FC<{
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>
|
||||
{tag.startsWith(TAG_PREFIXES.VARIABLE) ? tag.substring(TAG_PREFIXES.VARIABLE.length) : tag}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={
|
||||
tag.startsWith(TAG_PREFIXES.VARIABLE) ? tag.substring(TAG_PREFIXES.VARIABLE.length) : tag
|
||||
}
|
||||
className='flex-1'
|
||||
/>
|
||||
{variableInfo && (
|
||||
<span className='ml-auto text-[var(--text-muted-inverse)] text-micro'>
|
||||
{variableInfo.type}
|
||||
@@ -800,7 +804,7 @@ const BlockRootTagItem: React.FC<{
|
||||
fallbackLabel={blockName.charAt(0).toUpperCase()}
|
||||
size='sm'
|
||||
/>
|
||||
<span className='flex-1 truncate'>{blockName}</span>
|
||||
<OverflowText label={blockName} className='flex-1' />
|
||||
</PopoverItem>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ import {
|
||||
ExpandableContent,
|
||||
handleKeyboardActivation,
|
||||
Info,
|
||||
OverflowText,
|
||||
} from '@sim/emcn'
|
||||
import { ChevronDown, Search } from '@sim/emcn/icons'
|
||||
import clsx from 'clsx'
|
||||
@@ -134,7 +135,7 @@ const ToolbarItem = memo(function ToolbarItem({
|
||||
bgColor={item.bgColor}
|
||||
data-toolbar-item-icon=''
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{item.name}</span>
|
||||
<OverflowText label={item.name} className='flex-1 text-[var(--text-body)]' />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
+7
-6
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
Loader,
|
||||
OverflowText,
|
||||
} from '@sim/emcn'
|
||||
import { Folder, MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons'
|
||||
import Link from 'next/link'
|
||||
@@ -95,7 +96,7 @@ function CollapsedFlyoutRows({
|
||||
<DropdownMenuItem key={entry.id} asChild active={currentItemId === entry.id}>
|
||||
<Link href={entry.href}>
|
||||
<Icon className='size-[14px]' />
|
||||
<span className='truncate'>{entry.name}</span>
|
||||
<OverflowText label={entry.name} />
|
||||
{entry.pinned && <PinnedGlyph />}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -106,7 +107,7 @@ function CollapsedFlyoutRows({
|
||||
return (
|
||||
<DropdownMenuItem key={entry.id} disabled>
|
||||
<Folder className='size-[14px]' />
|
||||
<span className='truncate'>{entry.name}</span>
|
||||
<OverflowText label={entry.name} />
|
||||
{entry.pinned && <PinnedGlyph />}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
@@ -116,7 +117,7 @@ function CollapsedFlyoutRows({
|
||||
<DropdownMenuSub key={entry.id}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Folder className='size-[14px]' />
|
||||
<span className='truncate'>{entry.name}</span>
|
||||
<OverflowText label={entry.name} />
|
||||
{entry.pinned && <PinnedGlyph />}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
@@ -463,7 +464,7 @@ export function CollapsedWorkflowFlyoutItem({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>{workflow.name}</span>
|
||||
<OverflowText label={workflow.name} className='flex-1' />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
@@ -520,7 +521,7 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {
|
||||
return (
|
||||
<DropdownMenuItem key={folder.id} disabled>
|
||||
<Folder className='size-[14px]' />
|
||||
<span className='truncate'>{folder.name}</span>
|
||||
<OverflowText label={folder.name} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -529,7 +530,7 @@ export function CollapsedFolderItems(props: CollapsedFolderItemsProps) {
|
||||
<DropdownMenuSub key={folder.id}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Folder className='size-[14px]' />
|
||||
<span className='truncate'>{folder.name}</span>
|
||||
<OverflowText label={folder.name} />
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{interleaveSiblings(folder.children, folderWorkflows).map((child) =>
|
||||
|
||||
+18
-13
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { OverflowText } from '@sim/emcn'
|
||||
import { File, Workflow } from '@sim/emcn/icons'
|
||||
import { Command } from 'cmdk'
|
||||
import { HEX_COLOR_REGEX } from '@/lib/branding'
|
||||
@@ -33,13 +34,14 @@ function ItemFolderPath({ folderPath }: ItemFolderPathProps) {
|
||||
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
|
||||
{folderPath.length > 1 && (
|
||||
<>
|
||||
<span className='min-w-0 truncate [flex-shrink:9999]'>
|
||||
{folderPath.slice(0, -1).join(' / ')}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={folderPath.slice(0, -1).join(' / ')}
|
||||
className='[flex-shrink:9999]'
|
||||
/>
|
||||
<span className='flex-shrink-0 whitespace-pre'> / </span>
|
||||
</>
|
||||
)}
|
||||
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
|
||||
<OverflowText label={folderPath[folderPath.length - 1]} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -91,10 +93,13 @@ export const MemoizedCommandItem = memo(
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
|
||||
<BlockTile blockType={blockType} icon={Icon} bgColor={bgColor} />
|
||||
<span className='truncate text-[var(--text-body)]'>
|
||||
<OverflowText
|
||||
label={`${labelPrefix ? `${labelPrefix} ` : ''}${label}`}
|
||||
className='text-[var(--text-body)]'
|
||||
>
|
||||
{labelPrefix && <span className='text-[var(--text-subtle)]'>{labelPrefix} </span>}
|
||||
{label}
|
||||
</span>
|
||||
</OverflowText>
|
||||
{meta ? <ItemMeta meta={meta} /> : null}
|
||||
</Command.Item>
|
||||
)
|
||||
@@ -127,7 +132,7 @@ export const MemoizedActionItem = memo(
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
|
||||
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate text-[var(--text-body)]'>{name}</span>
|
||||
<OverflowText label={name} className='text-[var(--text-body)]' />
|
||||
{meta ? <ItemMeta meta={meta} /> : shortcut ? <ShortcutHint shortcut={shortcut} /> : null}
|
||||
</Command.Item>
|
||||
)
|
||||
@@ -161,7 +166,7 @@ export const MemoizedWorkflowItem = memo(
|
||||
<Workflow className='size-[14px] text-[var(--text-icon)]' />
|
||||
</div>
|
||||
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
|
||||
<span className='truncate'>{name}</span>
|
||||
<OverflowText label={name} />
|
||||
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
|
||||
</span>
|
||||
{meta ? (
|
||||
@@ -199,7 +204,7 @@ export const MemoizedFileItem = memo(
|
||||
<File className='size-[14px] text-[var(--text-icon)]' />
|
||||
</div>
|
||||
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
|
||||
<span className='truncate'>{name}</span>
|
||||
<OverflowText label={name} />
|
||||
</span>
|
||||
{meta ? (
|
||||
<ItemMeta meta={meta} />
|
||||
@@ -229,7 +234,7 @@ export const MemoizedTaskItem = memo(
|
||||
} & ResultMetaProps) {
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
|
||||
<span className='truncate text-[var(--text-body)]'>{name}</span>
|
||||
<OverflowText label={name} className='text-[var(--text-body)]' />
|
||||
{meta && <ItemMeta meta={meta} />}
|
||||
</Command.Item>
|
||||
)
|
||||
@@ -278,7 +283,7 @@ export const MemoizedWorkspaceItem = memo(
|
||||
</span>
|
||||
)}
|
||||
<span className='flex min-w-0 text-[var(--text-body)]'>
|
||||
<span className='truncate'>{name}</span>
|
||||
<OverflowText label={name} />
|
||||
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
|
||||
</span>
|
||||
{meta && <ItemMeta meta={meta} />}
|
||||
@@ -312,7 +317,7 @@ export const MemoizedPageItem = memo(
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
|
||||
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate text-[var(--text-body)]'>{name}</span>
|
||||
<OverflowText label={name} className='text-[var(--text-body)]' />
|
||||
{meta ? <ItemMeta meta={meta} /> : shortcut ? <ShortcutHint shortcut={shortcut} /> : null}
|
||||
</Command.Item>
|
||||
)
|
||||
@@ -344,7 +349,7 @@ export const MemoizedIconItem = memo(
|
||||
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
|
||||
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
|
||||
<span className='truncate'>{name}</span>
|
||||
<OverflowText label={name} />
|
||||
</span>
|
||||
{meta ? (
|
||||
<ItemMeta meta={meta} />
|
||||
|
||||
+7
-4
@@ -7,6 +7,7 @@ import {
|
||||
chipIconSlotClass,
|
||||
chipVariants,
|
||||
cn,
|
||||
OverflowText,
|
||||
} from '@sim/emcn'
|
||||
import { ChevronLeft } from '@sim/emcn/icons'
|
||||
import { type QueryClient, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -345,7 +346,7 @@ export function SettingsSidebar({
|
||||
<span aria-hidden className={cn(chipIconSlotClass, 'text-[var(--text-icon)]')}>
|
||||
<ChevronLeft className='size-[14px]' />
|
||||
</span>
|
||||
<span className='sidebar-collapse-hide truncate text-[var(--text-body)]'>Back</span>
|
||||
<span className='sidebar-collapse-hide text-[var(--text-body)]'>Back</span>
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
</div>
|
||||
@@ -396,9 +397,11 @@ export function SettingsSidebar({
|
||||
const content = (
|
||||
<>
|
||||
<Icon className={chipContentIconClass} />
|
||||
<span className='sidebar-collapse-hide min-w-0 truncate text-[var(--text-body)]'>
|
||||
{item.label}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={item.label}
|
||||
className='sidebar-collapse-hide text-[var(--text-body)]'
|
||||
tooltipEnabled={!showCollapsedTooltips}
|
||||
/>
|
||||
{isLocked && (
|
||||
<span className='sidebar-collapse-hide ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
|
||||
Max
|
||||
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { cn, disclosureChevronClass, Expandable, ExpandableContent } from '@sim/emcn'
|
||||
import { cn, disclosureChevronClass, Expandable, ExpandableContent, OverflowText } from '@sim/emcn'
|
||||
import { ChevronDown } from '@sim/emcn/icons'
|
||||
|
||||
/**
|
||||
@@ -60,9 +60,10 @@ export function SidebarSection({
|
||||
}
|
||||
|
||||
const label = (
|
||||
<span className='sidebar-collapse-hide min-w-0 truncate text-[var(--text-muted)] text-caption'>
|
||||
{title}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={title}
|
||||
className='sidebar-collapse-hide text-[var(--text-muted)] text-caption'
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
+13
-8
@@ -1,7 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { chipContentIconClass, chipVariants, cn, disclosureChevronClass, toast } from '@sim/emcn'
|
||||
import {
|
||||
chipContentIconClass,
|
||||
chipVariants,
|
||||
cn,
|
||||
disclosureChevronClass,
|
||||
OverflowText,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { ChevronRight, Folder, FolderOpen, Lock, MoreHorizontal } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
@@ -535,13 +542,11 @@ export const FolderItem = memo(function FolderItem({ workspaceId, folder }: Fold
|
||||
/>
|
||||
) : (
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-1'>
|
||||
<span
|
||||
className='min-w-0 truncate text-[var(--text-body)]'
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
<div
|
||||
className='flex min-w-0 flex-1 items-center gap-1'
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<OverflowText label={folder.name} className='flex-1 text-[var(--text-body)]' />
|
||||
</div>
|
||||
<div className='relative size-[18px] flex-shrink-0'>
|
||||
{folder.locked && (
|
||||
|
||||
+3
-6
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { chipVariants, cn } from '@sim/emcn'
|
||||
import { chipVariants, cn, OverflowText } from '@sim/emcn'
|
||||
import { Lock, MoreHorizontal } from '@sim/emcn/icons'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
@@ -441,11 +441,8 @@ export const WorkflowItem = memo(function WorkflowItem({
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className='min-w-0 truncate text-[var(--text-body)]'
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{workflow.name}
|
||||
<div className='min-w-0' onDoubleClick={handleDoubleClick}>
|
||||
<OverflowText label={workflow.name} className='block text-[var(--text-body)]' />
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && <Avatars workflowId={workflow.id} />}
|
||||
|
||||
+17
-5
@@ -1,6 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader, toast } from '@sim/emcn'
|
||||
import {
|
||||
Chip,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
OverflowText,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -100,10 +108,14 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa
|
||||
invitations.map((inv) => (
|
||||
<div key={inv.id} className='flex items-center gap-2 px-2'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate text-[var(--text-body)] text-sm'>{invitationLabel(inv)}</p>
|
||||
<p className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{invitationSubLabel(inv)}
|
||||
</p>
|
||||
<OverflowText
|
||||
label={invitationLabel(inv)}
|
||||
className='block text-[var(--text-body)] text-sm'
|
||||
/>
|
||||
<OverflowText
|
||||
label={invitationSubLabel(inv)}
|
||||
className='block text-[var(--text-muted)] text-caption'
|
||||
/>
|
||||
</div>
|
||||
<Chip
|
||||
variant='primary'
|
||||
|
||||
+5
-3
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
OverflowText,
|
||||
Plus,
|
||||
Send,
|
||||
Skeleton,
|
||||
@@ -760,9 +761,10 @@ function WorkspaceHeaderImpl({
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>
|
||||
{workspace.name}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={workspace.name}
|
||||
className='flex-1 text-[var(--text-body)] text-sm'
|
||||
/>
|
||||
{/* Pin and options share one fixed slot, as the chat rows do:
|
||||
the trailing width never changes, so pinning cannot re-truncate
|
||||
the name under the user's cursor. */}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Home,
|
||||
Library,
|
||||
Loader,
|
||||
OverflowText,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
Upload,
|
||||
@@ -275,7 +276,7 @@ const SidebarChatItem = memo(function SidebarChatItem({
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className='min-w-0 flex-1 truncate text-[var(--text-body)]'>{chat.name}</div>
|
||||
<OverflowText label={chat.name} className='flex-1 text-[var(--text-body)]' />
|
||||
{chat.id !== 'new' && (
|
||||
<div className='relative flex size-[18px] flex-shrink-0 items-center justify-center'>
|
||||
{showStatusDot && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Avatar, AvatarFallback, Chip, ChipDropdown, cn } from '@sim/emcn'
|
||||
import { Avatar, AvatarFallback, Chip, ChipDropdown, cn, OverflowText } from '@sim/emcn'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import type { MemberRole } from './member-role-options'
|
||||
import { RoleLockTooltip } from './role-lock'
|
||||
@@ -63,12 +63,14 @@ export function MemberRow<TRole extends string = MemberRole>({
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<span className='truncate text-[var(--text-body)] text-sm'>
|
||||
{member.userName || member.userEmail || member.userId}
|
||||
</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{member.userEmail || member.userId}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={member.userName || member.userEmail || member.userId}
|
||||
className='text-[var(--text-body)] text-sm'
|
||||
/>
|
||||
<OverflowText
|
||||
label={member.userEmail || member.userId}
|
||||
className='text-[var(--text-muted)] text-caption'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RoleLockTooltip reason={lockReason}>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChipConfirmModal, chipIconSlotClass, chipVariants, cn, Tooltip } from '@sim/emcn'
|
||||
import {
|
||||
ChipConfirmModal,
|
||||
chipIconSlotClass,
|
||||
chipVariants,
|
||||
cn,
|
||||
OverflowText,
|
||||
Tooltip,
|
||||
} from '@sim/emcn'
|
||||
import { ChevronLeft } from '@sim/emcn/icons'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -119,7 +126,7 @@ export function SettingsSidebar<Section extends SettingsSection>({
|
||||
<span aria-hidden className={cn(chipIconSlotClass, 'text-[var(--text-icon)]')}>
|
||||
<ChevronLeft className='size-[14px]' />
|
||||
</span>
|
||||
<span className='sidebar-collapse-hide truncate text-[var(--text-body)]'>Back</span>
|
||||
<span className='sidebar-collapse-hide text-[var(--text-body)]'>Back</span>
|
||||
</button>
|
||||
</SidebarTooltip>
|
||||
)}
|
||||
@@ -175,9 +182,10 @@ export function SettingsSidebar<Section extends SettingsSection>({
|
||||
}}
|
||||
>
|
||||
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='sidebar-collapse-hide min-w-0 truncate text-[var(--text-body)]'>
|
||||
{item.label}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={item.label}
|
||||
className='sidebar-collapse-hide text-[var(--text-body)]'
|
||||
/>
|
||||
{item.locked && (
|
||||
<span className='sidebar-collapse-hide ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[var(--text-icon)] text-micro uppercase tracking-wide'>
|
||||
Plan
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ChipTag,
|
||||
cn,
|
||||
Info,
|
||||
OverflowText,
|
||||
Search,
|
||||
Skeleton,
|
||||
Switch,
|
||||
@@ -305,10 +306,14 @@ function AddMembersModal({
|
||||
<Checkbox checked={isSelected} />
|
||||
<MemberAvatar name={name} image={member.user?.image ?? null} />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate text-[var(--text-body)] text-sm'>{name}</div>
|
||||
<div className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{email}
|
||||
</div>
|
||||
<OverflowText
|
||||
label={name}
|
||||
className='block text-[var(--text-body)] text-sm'
|
||||
/>
|
||||
<OverflowText
|
||||
label={email}
|
||||
className='block text-[var(--text-muted)] text-caption'
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -428,7 +433,7 @@ function CheckboxGrid({
|
||||
checked={isAllowed(item.id)}
|
||||
onCheckedChange={() => onToggle(item.id)}
|
||||
/>
|
||||
<span className='truncate text-sm'>{item.label}</span>
|
||||
<OverflowText label={item.label} className='text-sm' />
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
@@ -524,7 +529,7 @@ function ProviderRow({
|
||||
isProviderAllowed ? 'cursor-pointer' : 'cursor-default opacity-60'
|
||||
)}
|
||||
>
|
||||
<span className='truncate text-sm'>{providerName}</span>
|
||||
<OverflowText label={providerName} className='text-sm' />
|
||||
{isProviderAllowed && deniedCount > 0 && (
|
||||
<ChipTag variant='gray' className='flex-shrink-0'>
|
||||
{deniedCount} blocked
|
||||
@@ -611,7 +616,7 @@ function BlockToolRow({
|
||||
!isBlockAllowed && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<span className='truncate text-sm'>{block.name}</span>
|
||||
<OverflowText label={block.name} className='text-sm' />
|
||||
{/* An org running one custom block per environment has prod/uat/sandbox copies
|
||||
sharing a name and differing only by an opaque type slug. The source workspace
|
||||
is the only thing that tells them apart, so an allowlist decision made without
|
||||
@@ -1651,7 +1656,7 @@ export function GroupDetail({
|
||||
>
|
||||
{BlockIcon && <BlockIcon className='!size-[9px] text-white' />}
|
||||
</div>
|
||||
<span className='truncate text-sm'>{block.name}</span>
|
||||
<OverflowText label={block.name} className='text-sm' />
|
||||
{block.sourceWorkspaceName && (
|
||||
<span className='flex-shrink-0 text-[var(--text-muted)] text-caption'>
|
||||
{block.sourceWorkspaceName}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ChipSelect,
|
||||
type ComboboxOption,
|
||||
Download,
|
||||
OverflowText,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
@@ -419,8 +420,13 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
placeholder='All time'
|
||||
overlayLabel={timeDisplayLabel}
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
|
||||
<OverflowText
|
||||
label={timeDisplayLabel}
|
||||
className='block w-full text-[var(--text-primary)]'
|
||||
tooltipEnabled={false}
|
||||
/>
|
||||
}
|
||||
maxHeight={320}
|
||||
align='start'
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChipSwitch,
|
||||
ChipTag,
|
||||
Info,
|
||||
OverflowText,
|
||||
Search,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
@@ -336,7 +337,7 @@ function EntityCheckboxGrid({
|
||||
checked={selected.includes(entity.value)}
|
||||
onCheckedChange={() => toggle(entity.value)}
|
||||
/>
|
||||
<span className='truncate text-sm'>{entity.label}</span>
|
||||
<OverflowText label={entity.label} className='text-sm' />
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
|
||||
+6
-4
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { Checkbox, ChevronDown, cn, toast } from '@sim/emcn'
|
||||
import { Checkbox, ChevronDown, cn, OverflowText, toast } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { useUpdateForkExcludedWorkflows } from '@/ee/workspace-forking/hooks/workspace-fork'
|
||||
@@ -205,12 +205,14 @@ function ExcludedFolderRow({
|
||||
className='flex min-w-0 items-center gap-1.5 text-left hover:text-[var(--text-primary)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<span className='min-w-0 truncate'>
|
||||
<OverflowText
|
||||
label={`${folder.name} (${selectedCount > 0 ? `${selectedCount}/${total}` : total})`}
|
||||
>
|
||||
{folder.name}{' '}
|
||||
<span className='text-[var(--text-muted)]'>
|
||||
({selectedCount > 0 ? `${selectedCount}/${total}` : total})
|
||||
</span>
|
||||
</span>
|
||||
</OverflowText>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
@@ -288,7 +290,7 @@ function ExcludedWorkflowRow({
|
||||
onCheckedChange={(value) => onToggle([workflow.id], value === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className='min-w-0 truncate'>{workflow.name}</span>
|
||||
<OverflowText label={workflow.name} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
|
||||
import { Checkbox, ChevronDown, cn, OverflowText } from '@sim/emcn'
|
||||
|
||||
export interface ForkFileTreeItem {
|
||||
id: string
|
||||
@@ -137,9 +137,10 @@ function ForkFileFolderRow({
|
||||
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{folder.name} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
|
||||
</span>
|
||||
<OverflowText
|
||||
label={`${folder.name} (${selectedCount > 0 ? `${selectedCount}/${total}` : total})`}
|
||||
className='flex-1'
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
@@ -191,7 +192,7 @@ function ForkFileRow({ file, checked, onToggle, disabled }: ForkFileRowProps) {
|
||||
onCheckedChange={(value) => onToggle(file.id, value === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate'>{file.label}</span>
|
||||
<OverflowText label={file.label} className='flex-1' />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
+10
-8
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { Checkbox, ChevronDown, cn } from '@sim/emcn'
|
||||
import { Checkbox, ChevronDown, cn, OverflowText } from '@sim/emcn'
|
||||
import {
|
||||
ForkFileTree,
|
||||
type ForkFlatFile,
|
||||
@@ -69,9 +69,10 @@ export function ResourceKindRow({
|
||||
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
|
||||
</span>
|
||||
<OverflowText
|
||||
label={`${label} (${selectedCount > 0 ? `${selectedCount}/${total}` : total})`}
|
||||
className='flex-1'
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
@@ -105,7 +106,7 @@ export function ResourceKindRow({
|
||||
onCheckedChange={(checked) => onToggleItem(item.id, checked === true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className='truncate'>{item.label}</span>
|
||||
<OverflowText label={item.label} />
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
@@ -164,9 +165,10 @@ export function FileKindRow({
|
||||
className='flex min-w-0 flex-1 items-center gap-1 text-left hover:text-[var(--text-primary)]'
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{label} ({selectedCount > 0 ? `${selectedCount}/${total}` : total})
|
||||
</span>
|
||||
<OverflowText
|
||||
label={`${label} (${selectedCount > 0 ? `${selectedCount}/${total}` : total})`}
|
||||
className='flex-1'
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
cn,
|
||||
FieldDivider,
|
||||
Label,
|
||||
OverflowText,
|
||||
Tooltip,
|
||||
} from '@sim/emcn'
|
||||
import { ArrowRight } from '@sim/emcn/icons'
|
||||
@@ -482,7 +483,9 @@ function MappingEntry({ controller, group, entry }: MappingEntryProps) {
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<Label className='min-w-0 truncate'>{entry.sourceLabel}</Label>
|
||||
<Label className='min-w-0'>
|
||||
<OverflowText label={entry.sourceLabel} />
|
||||
</Label>
|
||||
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
|
||||
<ChipCombobox
|
||||
className='w-full'
|
||||
@@ -613,7 +616,7 @@ function MappingKindRow({ controller, group, summary }: MappingKindRowProps) {
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className='flex w-full items-center gap-2 text-left text-[var(--text-body)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>{group.label}</span>
|
||||
<OverflowText label={group.label} className='flex-1' />
|
||||
<Badge variant={badge.variant} size='sm' dot>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
@@ -733,14 +736,11 @@ function TriggerMappingRow({ controller, mapping }: TriggerMappingRowProps) {
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
{/* One inner span, so the name and its "in <workflow>" suffix share a normal inline flow:
|
||||
`Label` is inline-flex, and a flex container DISCARDS whitespace-only children, which
|
||||
eats the separating space (and leaves `truncate` with no text run to clip). */}
|
||||
<Label className='min-w-0'>
|
||||
<span className='min-w-0 truncate'>
|
||||
<OverflowText label={`${mapping.blockName} in ${mapping.workflowName}`}>
|
||||
{mapping.blockName}{' '}
|
||||
<span className='text-[var(--text-muted)]'>in {mapping.workflowName}</span>
|
||||
</span>
|
||||
</OverflowText>
|
||||
</Label>
|
||||
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
|
||||
{decidable ? (
|
||||
|
||||
@@ -57,6 +57,19 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('Combobox onOpenChange', () => {
|
||||
it('uses the overlay label for the interactive overflow layer', () => {
|
||||
render(
|
||||
<Combobox
|
||||
options={OPTIONS}
|
||||
overlayContent={<span>2 selected</span>}
|
||||
overlayLabel='2 selected'
|
||||
/>
|
||||
)
|
||||
|
||||
const overflowLabel = trigger().querySelector('[data-overflow-text]')
|
||||
expect(overflowLabel?.textContent).toBe('2 selected')
|
||||
})
|
||||
|
||||
it('reports the open a trigger click causes', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<Combobox options={OPTIONS} onOpenChange={onOpenChange} />)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Check, ChevronDown, Loader, Search } from '../../icons'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '../chip/chip-chrome'
|
||||
import { Input } from '../input/input'
|
||||
import { OverflowText } from '../overflow-text/overflow-text'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover'
|
||||
|
||||
const comboboxVariants = cva(
|
||||
@@ -93,8 +94,10 @@ export interface ComboboxProps
|
||||
disabled?: boolean
|
||||
/** Enable free-text input mode (default: false) */
|
||||
editable?: boolean
|
||||
/** Custom overlay content for editable mode */
|
||||
/** Visual content rendered over the selected value. */
|
||||
overlayContent?: ReactNode
|
||||
/** Plain-text value represented by a visual overlay in non-editable mode. */
|
||||
overlayLabel?: string
|
||||
/** Additional input props for editable mode */
|
||||
inputProps?: Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
@@ -172,6 +175,7 @@ const Combobox = memo(
|
||||
disabled,
|
||||
editable = false,
|
||||
overlayContent,
|
||||
overlayLabel,
|
||||
inputProps = {},
|
||||
inputRef: externalInputRef,
|
||||
filterOptions = editable,
|
||||
@@ -677,9 +681,10 @@ const Combobox = memo(
|
||||
) : (
|
||||
<>
|
||||
{SelectedIcon && <SelectedIcon className='mr-2 size-3 flex-shrink-0' />}
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
{selectedOption?.label}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={selectedOption?.label ?? ''}
|
||||
className='text-[var(--text-primary)]'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -716,15 +721,18 @@ const Combobox = memo(
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<span
|
||||
<OverflowText
|
||||
label={
|
||||
overlayLabel ??
|
||||
multiSelectLabel ??
|
||||
(selectedOption ? selectedOption.label : placeholder)
|
||||
}
|
||||
className={cn(
|
||||
'flex-1 truncate',
|
||||
'flex-1',
|
||||
!selectedOption && !multiSelectLabel && 'text-[var(--text-muted)]',
|
||||
overlayContent && 'text-transparent'
|
||||
)}
|
||||
>
|
||||
{multiSelectLabel ?? (selectedOption ? selectedOption.label : placeholder)}
|
||||
</span>
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-2 size-4 flex-shrink-0 opacity-50 transition-transform',
|
||||
@@ -899,9 +907,10 @@ const Combobox = memo(
|
||||
: OptionIcon && (
|
||||
<OptionIcon className='size-[14px] flex-shrink-0' />
|
||||
)}
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
{option.label}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={option.label}
|
||||
className='flex-1 text-[var(--text-primary)]'
|
||||
/>
|
||||
{option.suffixElement}
|
||||
{multiSelect && isSelected && (
|
||||
<Check className='ml-2 size-[12px] flex-shrink-0 text-[var(--text-primary)]' />
|
||||
@@ -935,9 +944,10 @@ const Combobox = memo(
|
||||
: chipHoverSurfaceClass
|
||||
)}
|
||||
>
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
{allOptionLabel}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={allOptionLabel}
|
||||
className='flex-1 text-[var(--text-primary)]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filteredOptions.map((option, index) => {
|
||||
@@ -973,9 +983,10 @@ const Combobox = memo(
|
||||
{option.iconElement
|
||||
? option.iconElement
|
||||
: OptionIcon && <OptionIcon className='size-[14px] flex-shrink-0' />}
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
{option.label}
|
||||
</span>
|
||||
<OverflowText
|
||||
label={option.label}
|
||||
className='flex-1 text-[var(--text-primary)]'
|
||||
/>
|
||||
{option.suffixElement}
|
||||
{multiSelect && isSelected && (
|
||||
<Check className='ml-2 size-[12px] flex-shrink-0 text-[var(--text-primary)]' />
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Menu rows are a fixed height, so a label that wraps overflows its row and paints over its
|
||||
* neighbours. Rows are held to one line and their bare text is wrapped in a truncating box so an
|
||||
* over-long label ellipsizes instead of being cut mid-word. These tests cover that wrapping:
|
||||
* neighbours. Rows are held to one line and their bare text is wrapped in the shared overflow
|
||||
* treatment so an over-long label fades instead of being cut mid-word. These tests cover that wrapping:
|
||||
* that it happens, that adjacent text stays in ONE box (two boxes would be two flex items, and
|
||||
* the row's `gap` would open between the words), and that it steps aside for `asChild`, where
|
||||
* Radix's `Slot` requires exactly one element child.
|
||||
@@ -51,13 +51,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('menu row labels', () => {
|
||||
it('wraps a bare text label in a truncating box', () => {
|
||||
it('wraps a bare text label in the shared overflow treatment', () => {
|
||||
openMenu(<DropdownMenuItem>Run empty or failed cells on 2 rows</DropdownMenuItem>)
|
||||
|
||||
const labels = row().querySelectorAll('span')
|
||||
expect(labels).toHaveLength(1)
|
||||
expect(labels[0].textContent).toBe('Run empty or failed cells on 2 rows')
|
||||
expect(labels[0].className).toContain('truncate')
|
||||
expect(labels[0].className).toContain('overflow-hidden')
|
||||
expect(labels[0].className).toContain('text-clip')
|
||||
expect(labels[0].className).not.toContain('truncate')
|
||||
})
|
||||
|
||||
it('keeps the row on one line', () => {
|
||||
@@ -84,7 +86,7 @@ describe('menu row labels', () => {
|
||||
openMenu(<DropdownMenuCheckboxItem checked>Show archived workflows</DropdownMenuCheckboxItem>)
|
||||
|
||||
const labels = row('[role="menuitemcheckbox"]').querySelectorAll('span')
|
||||
const label = Array.from(labels).find((node) => node.className.includes('truncate'))
|
||||
const label = Array.from(labels).find((node) => node.className.includes('text-clip'))
|
||||
expect(label?.textContent).toBe('Show archived workflows')
|
||||
})
|
||||
|
||||
@@ -108,5 +110,6 @@ describe('menu row labels', () => {
|
||||
)
|
||||
|
||||
expect(row().querySelectorAll('span')).toHaveLength(1)
|
||||
expect(row().className).toContain('[&>span:not([data-overflow-text])]:truncate')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Check, ChevronRight, Circle, Search } from '../../icons'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { chipContentGap, chipFieldSurfaceClass } from '../chip/chip-chrome'
|
||||
import { InsideModalContext } from '../modal/modal'
|
||||
import { OverflowText } from '../overflow-text/overflow-text'
|
||||
|
||||
const ANIMATION_CLASSES =
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
|
||||
@@ -76,30 +77,32 @@ const MENU_ROW_SELECTED_CLASS =
|
||||
/**
|
||||
* Rows are a fixed height, so a label that wraps overflows its row and paints
|
||||
* over its neighbours instead of growing the row. Every row is therefore held
|
||||
* to one line, and its label ellipsizes — see {@link withEllipsizedLabel}.
|
||||
* to one line, and its label uses the shared overflow treatment — see
|
||||
* {@link withOverflowLabel}.
|
||||
*/
|
||||
const MENU_ROW_SINGLE_LINE_CLASS = 'whitespace-nowrap [&>span]:min-w-0 [&>span]:truncate'
|
||||
const MENU_ROW_SINGLE_LINE_CLASS =
|
||||
'whitespace-nowrap [&>span]:min-w-0 [&>span:not([data-overflow-text])]:truncate'
|
||||
|
||||
/**
|
||||
* Wraps a row's bare text children in a truncating box so a label wider than
|
||||
* the menu ends in an ellipsis rather than being cut mid-word at the surface
|
||||
* edge. Consumers that already wrap their label in a `<span>` are unaffected —
|
||||
* the row's `[&>span]` rule truncates those in place.
|
||||
* the menu uses the platform overflow treatment rather than being cut mid-word
|
||||
* at the surface edge. Consumer-provided direct `<span>` labels retain an
|
||||
* ellipsis fallback; a canonical {@link OverflowText} owns its fade and tooltip.
|
||||
*
|
||||
* Adjacent text is coalesced into a single box: a row is a flex container, so
|
||||
* wrapping `Insert row {n}` as two boxes would make them two flex items and
|
||||
* open the row's `gap` between the words. `React.Children.toArray` keys the
|
||||
* element children it returns, so the rebuilt array needs no keys of its own.
|
||||
*/
|
||||
function withEllipsizedLabel(children: React.ReactNode): React.ReactNode {
|
||||
function withOverflowLabel(children: React.ReactNode): React.ReactNode {
|
||||
const rebuilt: React.ReactNode[] = []
|
||||
let text: React.ReactNode[] = []
|
||||
let text: Array<string | number> = []
|
||||
const flushText = () => {
|
||||
if (text.length === 0) return
|
||||
rebuilt.push(
|
||||
<span key={`label-${rebuilt.length}`} className='min-w-0 truncate'>
|
||||
<OverflowText key={`label-${rebuilt.length}`} label={text.join('')}>
|
||||
{text}
|
||||
</span>
|
||||
</OverflowText>
|
||||
)
|
||||
text = []
|
||||
}
|
||||
@@ -212,7 +215,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withEllipsizedLabel(children)}
|
||||
{withOverflowLabel(children)}
|
||||
<ChevronRight className='ml-auto size-[14px] shrink-0' />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
@@ -303,7 +306,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
action?: React.ReactNode
|
||||
}
|
||||
>(({ className, inset, active, action, asChild, children, ...props }, ref) => {
|
||||
const content = asChild ? children : withEllipsizedLabel(children)
|
||||
const content = asChild ? children : withOverflowLabel(children)
|
||||
const stateClasses = active ? MENU_ROW_SELECTED_CLASS : MENU_ROW_HIGHLIGHT_CLASS
|
||||
if (action) {
|
||||
return (
|
||||
@@ -389,7 +392,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<Check className='size-[14px]' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{withEllipsizedLabel(children)}
|
||||
{withOverflowLabel(children)}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
@@ -411,7 +414,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<Circle className='size-[6px] fill-current' />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{withEllipsizedLabel(children)}
|
||||
{withOverflowLabel(children)}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
@@ -163,6 +163,11 @@ export {
|
||||
useModalDismissDisabled,
|
||||
useNativeSurfaceOcclusionReady,
|
||||
} from './modal/modal'
|
||||
export {
|
||||
OverflowText,
|
||||
type OverflowTextProps,
|
||||
overflowTextFadeClass,
|
||||
} from './overflow-text/overflow-text'
|
||||
export {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, StrictMode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OverflowText } from './overflow-text'
|
||||
|
||||
let host: HTMLDivElement
|
||||
let root: Root
|
||||
let resizeObserverCallback: ResizeObserverCallback
|
||||
let resizeObserverCount: number
|
||||
let originalFontsDescriptor: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
resizeObserverCount = 0
|
||||
originalFontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
|
||||
const fontEvents = new EventTarget()
|
||||
Object.defineProperty(fontEvents, 'ready', {
|
||||
configurable: true,
|
||||
value: new Promise(() => {}),
|
||||
})
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
configurable: true,
|
||||
value: fontEvents,
|
||||
})
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeObserverCount += 1
|
||||
resizeObserverCallback = callback
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
host.remove()
|
||||
if (originalFontsDescriptor) {
|
||||
Object.defineProperty(document, 'fonts', originalFontsDescriptor)
|
||||
} else {
|
||||
Reflect.deleteProperty(document, 'fonts')
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function setWidths(element: HTMLElement, clientWidth: number, scrollWidth: number) {
|
||||
Object.defineProperties(element, {
|
||||
clientWidth: { configurable: true, value: clientWidth },
|
||||
scrollWidth: { configurable: true, value: scrollWidth },
|
||||
})
|
||||
act(() =>
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: element,
|
||||
contentRect: element.getBoundingClientRect(),
|
||||
borderBoxSize: [],
|
||||
contentBoxSize: [],
|
||||
devicePixelContentBoxSize: [],
|
||||
},
|
||||
],
|
||||
{} as ResizeObserver
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('OverflowText', () => {
|
||||
it('fades and reveals the full value only when clipped', () => {
|
||||
act(() =>
|
||||
root.render(<OverflowText label='A long workflow name' className='truncate text-sm' />)
|
||||
)
|
||||
const label = host.querySelector<HTMLElement>('span')
|
||||
if (!label) throw new Error('Overflow label did not render')
|
||||
|
||||
setWidths(label, 80, 180)
|
||||
|
||||
expect(label.classList.contains('overflow-hidden')).toBe(true)
|
||||
expect(label.classList.contains('block')).toBe(true)
|
||||
expect(label.classList.contains('text-clip')).toBe(true)
|
||||
expect(label.classList.contains('whitespace-nowrap')).toBe(true)
|
||||
expect(label.classList.contains('truncate')).toBe(false)
|
||||
expect(label.classList.contains('text-sm')).toBe(true)
|
||||
expect(label.className).toContain('-webkit-mask-image:linear-gradient')
|
||||
expect(label.className).toContain('mask-image:linear-gradient')
|
||||
|
||||
act(() => {
|
||||
label.dispatchEvent(
|
||||
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
|
||||
)
|
||||
})
|
||||
expect(document.querySelector('[data-native-surface-overlay]')?.textContent).toBe(
|
||||
'A long workflow name'
|
||||
)
|
||||
|
||||
setWidths(label, 200, 180)
|
||||
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a fitting label unmasked and does not open a tooltip', () => {
|
||||
act(() => root.render(<OverflowText label='Short' />))
|
||||
const label = host.querySelector<HTMLElement>('span')
|
||||
if (!label) throw new Error('Overflow label did not render')
|
||||
|
||||
setWidths(label, 100, 60)
|
||||
expect(label.className).not.toContain('mask-image:linear-gradient')
|
||||
|
||||
act(() => {
|
||||
label.dispatchEvent(
|
||||
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
|
||||
)
|
||||
})
|
||||
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
|
||||
})
|
||||
|
||||
it('can disable the tooltip for a visual mirror layer', () => {
|
||||
act(() => root.render(<OverflowText label='A long workflow name' tooltipEnabled={false} />))
|
||||
const label = host.querySelector<HTMLElement>('span')
|
||||
if (!label) throw new Error('Overflow label did not render')
|
||||
|
||||
setWidths(label, 80, 180)
|
||||
expect(label.className).toContain('mask-image:linear-gradient')
|
||||
|
||||
act(() => {
|
||||
label.dispatchEvent(
|
||||
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
|
||||
)
|
||||
})
|
||||
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens from an external composite control keyboard focus', () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<button type='button'>
|
||||
<OverflowText label='A long workflow name' focusTarget='nearest-interactive' />
|
||||
</button>
|
||||
)
|
||||
)
|
||||
const button = host.querySelector('button')
|
||||
const label = host.querySelector<HTMLElement>('[data-overflow-text]')
|
||||
if (!button || !label) throw new Error('Composite label did not render')
|
||||
|
||||
setWidths(label, 80, 180)
|
||||
vi.spyOn(button, 'matches').mockReturnValue(true)
|
||||
act(() => button.focus())
|
||||
|
||||
expect(document.querySelector('[data-native-surface-overlay]')?.textContent).toBe(
|
||||
'A long workflow name'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps decorated visible content out of the plain tooltip label', () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<OverflowText label='Workflow production'>
|
||||
Workflow <mark>production</mark>
|
||||
</OverflowText>
|
||||
)
|
||||
)
|
||||
const label = host.querySelector<HTMLElement>('span')
|
||||
if (!label) throw new Error('Overflow label did not render')
|
||||
setWidths(label, 80, 180)
|
||||
|
||||
act(() => {
|
||||
label.dispatchEvent(
|
||||
new MouseEvent('pointerover', { bubbles: true, clientX: 100, clientY: 100 })
|
||||
)
|
||||
})
|
||||
|
||||
const tooltip = document.querySelector('[data-native-surface-overlay]')
|
||||
expect(tooltip?.textContent).toBe('Workflow production')
|
||||
expect(tooltip?.querySelector('mark')).toBeNull()
|
||||
})
|
||||
|
||||
it('shares one resize observer across overflow labels', () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<>
|
||||
<OverflowText label='First workflow' />
|
||||
<OverflowText label='Second workflow' />
|
||||
</>
|
||||
)
|
||||
)
|
||||
|
||||
expect(resizeObserverCount).toBe(1)
|
||||
})
|
||||
|
||||
it('remeasures when an existing label changes', () => {
|
||||
act(() => root.render(<OverflowText label='Short' />))
|
||||
const label = host.querySelector<HTMLElement>('span')
|
||||
if (!label) throw new Error('Overflow label did not render')
|
||||
|
||||
setWidths(label, 100, 60)
|
||||
expect(label.className).not.toContain('mask-image:linear-gradient')
|
||||
|
||||
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 180 })
|
||||
act(() => root.render(<OverflowText label='A newly long workflow name' />))
|
||||
expect(label.className).toContain('mask-image:linear-gradient')
|
||||
|
||||
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 60 })
|
||||
act(() => root.render(<OverflowText label='Short again' />))
|
||||
expect(label.className).not.toContain('mask-image:linear-gradient')
|
||||
})
|
||||
|
||||
it('remeasures when decorated content changes without changing the label', () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<OverflowText label='Workflow production'>
|
||||
Workflow <mark>production</mark>
|
||||
</OverflowText>
|
||||
)
|
||||
)
|
||||
const label = host.querySelector<HTMLElement>('span')
|
||||
if (!label) throw new Error('Overflow label did not render')
|
||||
|
||||
setWidths(label, 100, 60)
|
||||
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 180 })
|
||||
act(() =>
|
||||
root.render(
|
||||
<OverflowText label='Workflow production'>
|
||||
Workflow <strong>production</strong>
|
||||
</OverflowText>
|
||||
)
|
||||
)
|
||||
|
||||
expect(label.className).toContain('mask-image:linear-gradient')
|
||||
})
|
||||
|
||||
it('remeasures when loaded fonts change text width', () => {
|
||||
act(() => root.render(<OverflowText label='Workflow name' />))
|
||||
const label = host.querySelector<HTMLElement>('span')
|
||||
if (!label) throw new Error('Overflow label did not render')
|
||||
|
||||
setWidths(label, 100, 60)
|
||||
expect(label.className).not.toContain('mask-image:linear-gradient')
|
||||
|
||||
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 180 })
|
||||
act(() => document.fonts.dispatchEvent(new Event('loadingdone')))
|
||||
expect(label.className).toContain('mask-image:linear-gradient')
|
||||
|
||||
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 60 })
|
||||
act(() => document.fonts.dispatchEvent(new Event('loadingerror')))
|
||||
expect(label.className).not.toContain('mask-image:linear-gradient')
|
||||
})
|
||||
|
||||
it('keeps observing mounted labels through Strict Mode effect replay', () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<OverflowText label='Workflow name' />
|
||||
</StrictMode>
|
||||
)
|
||||
)
|
||||
const label = host.querySelector<HTMLElement>('span')
|
||||
if (!label) throw new Error('Overflow label did not render')
|
||||
|
||||
setWidths(label, 100, 60)
|
||||
Object.defineProperty(label, 'scrollWidth', { configurable: true, value: 180 })
|
||||
act(() => document.fonts.dispatchEvent(new Event('loadingdone')))
|
||||
|
||||
expect(label.className).toContain('mask-image:linear-gradient')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { cn } from '../../lib/cn'
|
||||
import {
|
||||
FloatingTooltip,
|
||||
isTextClipped,
|
||||
useFloatingTooltip,
|
||||
useIsOverflowing,
|
||||
} from '../tooltip/tooltip'
|
||||
|
||||
/** Shared 18px trailing fade for measured special cases such as breadcrumb groups. */
|
||||
export const overflowTextFadeClass =
|
||||
'[-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_18px),transparent)] [mask-image:linear-gradient(to_right,black_calc(100%_-_18px),transparent)]'
|
||||
|
||||
export interface OverflowTextProps {
|
||||
/** Full text shown in the tooltip and used as the default visible content. */
|
||||
label: string
|
||||
/** Decorated rendering of `label`; the tooltip always keeps the plain label. */
|
||||
children?: ReactNode
|
||||
/** Layout and typography only; truncation and fade chrome are owned here. */
|
||||
className?: string
|
||||
/** Forces the tooltip when the visible label was shortened before rendering. */
|
||||
showWhen?: boolean
|
||||
/** Whether the full-value tooltip may open. Disable for visual mirror layers. */
|
||||
tooltipEnabled?: boolean
|
||||
/** Lets the nearest interactive ancestor own keyboard focus for this label. */
|
||||
focusTarget?: 'nearest-interactive'
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-line, read-only label that fades only when clipped and exposes its
|
||||
* complete value in the platform floating tooltip.
|
||||
*
|
||||
* Use this for human-readable names and titles in constrained chrome. Keep
|
||||
* editable values, code, logs, paths, dense grids, and multiline copy on their
|
||||
* purpose-built overflow behavior.
|
||||
*/
|
||||
export const OverflowText = memo(function OverflowText({
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
showWhen,
|
||||
tooltipEnabled = true,
|
||||
focusTarget,
|
||||
}: OverflowTextProps) {
|
||||
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>(children ?? label)
|
||||
const tooltipEligible = tooltipEnabled && label.length > 0 && (Boolean(showWhen) || isOverflowing)
|
||||
const getFocusTarget = useCallback(() => {
|
||||
if (focusTarget !== 'nearest-interactive') return null
|
||||
return (
|
||||
node.current?.closest<HTMLElement>(
|
||||
'a[href], button, [role="button"], [tabindex]:not([tabindex="-1"])'
|
||||
) ?? null
|
||||
)
|
||||
}, [focusTarget, node])
|
||||
const { state, handlers } = useFloatingTooltip(
|
||||
() => {
|
||||
const element = node.current
|
||||
if (!tooltipEnabled || !element || label.length === 0) return false
|
||||
return Boolean(showWhen) || isTextClipped(element)
|
||||
},
|
||||
{
|
||||
getFocusTarget: focusTarget === 'nearest-interactive' ? getFocusTarget : undefined,
|
||||
revalidateKey: tooltipEligible,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={textRef}
|
||||
data-overflow-text=''
|
||||
className={cn(
|
||||
className,
|
||||
'block min-w-0 overflow-hidden text-clip whitespace-nowrap',
|
||||
isOverflowing && overflowTextFadeClass
|
||||
)}
|
||||
{...handlers}
|
||||
>
|
||||
{children ?? label}
|
||||
</span>
|
||||
<FloatingTooltip label={label} state={state} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||||
import { Plus, X } from '../../icons'
|
||||
import { cn } from '../../lib/cn'
|
||||
import { Button } from '../button/button'
|
||||
import { overflowTextFadeClass } from '../overflow-text/overflow-text'
|
||||
import { Tooltip } from '../tooltip/tooltip'
|
||||
|
||||
const DRAG_EDGE_ZONE = 40
|
||||
@@ -441,7 +442,14 @@ const Tab = forwardRef<HTMLDivElement, TabProps>(function Tab(
|
||||
>
|
||||
{tab.icon}
|
||||
{!tab.pinned && (
|
||||
<span ref={titleRef} className='min-w-0 flex-1 select-none truncate text-left'>
|
||||
<span
|
||||
ref={titleRef}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 select-none truncate text-left',
|
||||
titleTruncated && overflowTextFadeClass,
|
||||
titleTruncated && 'hover:[-webkit-mask-image:none] hover:[mask-image:none]'
|
||||
)}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -83,11 +83,10 @@ const HIDDEN_STATE: FloatingTooltipState = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a pointer-reactive floating tooltip. `canShow` is queried on every
|
||||
* gesture with the event target, letting the caller gate the tooltip on its own
|
||||
* overflow measurement. Returns the current {@link FloatingTooltipState} to feed
|
||||
* a {@link FloatingTooltip} and a stable set of {@link FloatingTooltipHandlers}
|
||||
* to spread onto the trigger element.
|
||||
* Drives a pointer-reactive floating tooltip. `canShow` is checked on each
|
||||
* gesture and while visible, allowing callers to dismiss the tooltip when its
|
||||
* eligibility changes. Returns the current state and stable pointer/focus
|
||||
* handlers; `getFocusTarget` may supply a separate keyboard-focus trigger.
|
||||
*/
|
||||
export interface UseFloatingTooltipOptions {
|
||||
/**
|
||||
@@ -95,6 +94,10 @@ export interface UseFloatingTooltipOptions {
|
||||
* pointer is too close to the top of the viewport.
|
||||
*/
|
||||
preferAbove?: boolean
|
||||
/** Resolves an external control whose keyboard focus should reveal this tooltip. */
|
||||
getFocusTarget?: () => HTMLElement | null
|
||||
/** Semantic value whose changes should revalidate a visible tooltip. */
|
||||
revalidateKey?: unknown
|
||||
}
|
||||
|
||||
export function useFloatingTooltip(
|
||||
@@ -126,41 +129,49 @@ export function useFloatingTooltip(
|
||||
setState((current) => (current.visible ? HIDDEN_STATE : current))
|
||||
}, [reset])
|
||||
|
||||
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
|
||||
const apply = (clientX: number, clientY: number, motion: TooltipMotion) => {
|
||||
const next = { ...getTooltipPosition(clientX, clientY, preferAboveRef.current), ...motion }
|
||||
setState((current) =>
|
||||
current.visible &&
|
||||
current.x === next.x &&
|
||||
current.y === next.y &&
|
||||
current.alignX === next.alignX &&
|
||||
current.alignY === next.alignY &&
|
||||
current.skew === next.skew &&
|
||||
current.scaleX === next.scaleX &&
|
||||
current.scaleY === next.scaleY
|
||||
? current
|
||||
: { visible: true, ...next }
|
||||
)
|
||||
}
|
||||
const apply = React.useCallback((clientX: number, clientY: number, motion: TooltipMotion) => {
|
||||
const next = { ...getTooltipPosition(clientX, clientY, preferAboveRef.current), ...motion }
|
||||
setState((current) =>
|
||||
current.visible &&
|
||||
current.x === next.x &&
|
||||
current.y === next.y &&
|
||||
current.alignX === next.alignX &&
|
||||
current.alignY === next.alignY &&
|
||||
current.skew === next.skew &&
|
||||
current.scaleX === next.scaleX &&
|
||||
current.scaleY === next.scaleY
|
||||
? current
|
||||
: { visible: true, ...next }
|
||||
)
|
||||
}, [])
|
||||
|
||||
/** Reveals the tooltip at the pointer, seeding velocity tracking from it. */
|
||||
const showFromPointer = (clientX: number, clientY: number) => {
|
||||
const showFromPointer = React.useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
reset()
|
||||
lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() }
|
||||
apply(clientX, clientY, NEUTRAL_MOTION)
|
||||
}
|
||||
},
|
||||
[apply, reset]
|
||||
)
|
||||
|
||||
/**
|
||||
* Reveals the tooltip anchored to an element's box rather than the pointer.
|
||||
* Velocity tracking stays cleared: seeding it from the box would make the next
|
||||
* `pointermove` read the box-to-cursor delta as velocity and spike the flourish
|
||||
* when the pointer already happens to be over the trigger.
|
||||
*/
|
||||
const showFromElement = (clientX: number, clientY: number) => {
|
||||
/**
|
||||
* Anchors the tooltip to an element without seeding pointer velocity; using the
|
||||
* box position would make the next pointer move produce a false motion spike.
|
||||
*/
|
||||
const showFromElement = React.useCallback(
|
||||
(target: HTMLElement) => {
|
||||
reset()
|
||||
apply(clientX, clientY, NEUTRAL_MOTION)
|
||||
}
|
||||
const rect = target.getBoundingClientRect()
|
||||
apply(
|
||||
rect.left + rect.width / 2,
|
||||
preferAboveRef.current ? rect.top : rect.bottom,
|
||||
NEUTRAL_MOTION
|
||||
)
|
||||
},
|
||||
[apply, reset]
|
||||
)
|
||||
|
||||
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
|
||||
return {
|
||||
onPointerEnter: (event) => {
|
||||
if (!canShowRef.current(event.currentTarget)) return
|
||||
@@ -200,14 +211,32 @@ export function useFloatingTooltip(
|
||||
if (!canShowRef.current(target)) return
|
||||
if (!isFocusVisible(target)) return
|
||||
triggerRef.current = target
|
||||
const rect = target.getBoundingClientRect()
|
||||
/* Anchor on the edge the bubble grows away from, so a `preferAbove`
|
||||
tooltip measures from the trigger's top rather than its bottom. */
|
||||
showFromElement(rect.left + rect.width / 2, preferAboveRef.current ? rect.top : rect.bottom)
|
||||
showFromElement(target)
|
||||
},
|
||||
onBlur: hide,
|
||||
}
|
||||
}, [hide, reset])
|
||||
}, [apply, hide, showFromElement, showFromPointer])
|
||||
|
||||
React.useEffect(() => {
|
||||
const target = options.getFocusTarget?.()
|
||||
if (!target) return undefined
|
||||
const show = () => {
|
||||
if (!canShowRef.current(target) || !isFocusVisible(target)) return
|
||||
triggerRef.current = target
|
||||
showFromElement(target)
|
||||
}
|
||||
target.addEventListener('focus', show)
|
||||
target.addEventListener('blur', hide)
|
||||
return () => {
|
||||
target.removeEventListener('focus', show)
|
||||
target.removeEventListener('blur', hide)
|
||||
}
|
||||
}, [hide, options.getFocusTarget, showFromElement])
|
||||
|
||||
React.useEffect(() => {
|
||||
const trigger = triggerRef.current
|
||||
if (state.visible && (!trigger || !canShowRef.current(trigger))) hide()
|
||||
}, [hide, options.revalidateKey, state.visible])
|
||||
|
||||
/**
|
||||
* A keyboard- or script-driven UI change can hide the trigger with no pointer or focus event —
|
||||
@@ -227,23 +256,77 @@ export function useFloatingTooltip(
|
||||
return { state, handlers }
|
||||
}
|
||||
|
||||
const overflowMeasureByElement = new WeakMap<Element, () => void>()
|
||||
const observedOverflowElements = new Set<Element>()
|
||||
let sharedOverflowObserver: ResizeObserver | null = null
|
||||
let sharedOverflowFontSet: FontFaceSet | null = null
|
||||
|
||||
function measureObservedOverflow() {
|
||||
for (const element of observedOverflowElements) overflowMeasureByElement.get(element)?.()
|
||||
}
|
||||
|
||||
function observeOverflowFontChanges() {
|
||||
if (typeof document === 'undefined' || !document.fonts || sharedOverflowFontSet) return
|
||||
const fontSet = document.fonts
|
||||
sharedOverflowFontSet = fontSet
|
||||
fontSet.addEventListener('loadingdone', measureObservedOverflow)
|
||||
fontSet.addEventListener('loadingerror', measureObservedOverflow)
|
||||
void fontSet.ready.then(() => {
|
||||
if (sharedOverflowFontSet === fontSet) measureObservedOverflow()
|
||||
})
|
||||
}
|
||||
|
||||
function unobserveOverflowFontChanges() {
|
||||
sharedOverflowFontSet?.removeEventListener('loadingdone', measureObservedOverflow)
|
||||
sharedOverflowFontSet?.removeEventListener('loadingerror', measureObservedOverflow)
|
||||
sharedOverflowFontSet = null
|
||||
}
|
||||
|
||||
function observeOverflow(element: Element, measure: () => void): boolean {
|
||||
overflowMeasureByElement.set(element, measure)
|
||||
observedOverflowElements.add(element)
|
||||
observeOverflowFontChanges()
|
||||
if (typeof ResizeObserver === 'undefined') return false
|
||||
sharedOverflowObserver ??= new ResizeObserver((entries) => {
|
||||
for (const entry of entries) overflowMeasureByElement.get(entry.target)?.()
|
||||
})
|
||||
sharedOverflowObserver.observe(element)
|
||||
return true
|
||||
}
|
||||
|
||||
function unobserveOverflow(element: Element | null) {
|
||||
if (!element) return
|
||||
sharedOverflowObserver?.unobserve(element)
|
||||
overflowMeasureByElement.delete(element)
|
||||
observedOverflowElements.delete(element)
|
||||
if (observedOverflowElements.size === 0) {
|
||||
sharedOverflowObserver?.disconnect()
|
||||
sharedOverflowObserver = null
|
||||
unobserveOverflowFontChanges()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether an element's text is horizontally clipped, re-measuring via a
|
||||
* `ResizeObserver` and window resizes.
|
||||
* Tracks whether an element's text is horizontally clipped, re-measuring when
|
||||
* `measurementKey` or loaded fonts change and via a shared `ResizeObserver` (or
|
||||
* window resizes when the API is unavailable).
|
||||
*
|
||||
* Returns a callback `ref` to attach to the element — the observer follows the
|
||||
* element across mount, unmount, and reassignment, so it is safe to use on
|
||||
* conditionally rendered children. `node` is a stable ref for reading the
|
||||
* current element (e.g. for live measurements in event handlers).
|
||||
*
|
||||
* @param measurementKey - Value whose changes may alter the element's rendered width.
|
||||
*/
|
||||
export function useIsOverflowing<T extends HTMLElement = HTMLElement>(): {
|
||||
export function useIsOverflowing<T extends HTMLElement = HTMLElement>(
|
||||
measurementKey?: unknown
|
||||
): {
|
||||
ref: (node: T | null) => void
|
||||
node: React.RefObject<T | null>
|
||||
isOverflowing: boolean
|
||||
} {
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false)
|
||||
const nodeRef = React.useRef<T | null>(null)
|
||||
const observerRef = React.useRef<ResizeObserver | null>(null)
|
||||
|
||||
const measure = React.useCallback(() => {
|
||||
const element = nodeRef.current
|
||||
@@ -252,27 +335,31 @@ export function useIsOverflowing<T extends HTMLElement = HTMLElement>(): {
|
||||
|
||||
const ref = React.useCallback(
|
||||
(node: T | null) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
unobserveOverflow(nodeRef.current)
|
||||
nodeRef.current = node
|
||||
if (!node) return
|
||||
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(node)
|
||||
observerRef.current = observer
|
||||
observeOverflow(node, measure)
|
||||
},
|
||||
[measure]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener('resize', measure)
|
||||
const element = nodeRef.current
|
||||
if (!element) return undefined
|
||||
const usesResizeObserver = observeOverflow(element, measure)
|
||||
if (!usesResizeObserver) window.addEventListener('resize', measure)
|
||||
return () => {
|
||||
window.removeEventListener('resize', measure)
|
||||
observerRef.current?.disconnect()
|
||||
if (!usesResizeObserver) window.removeEventListener('resize', measure)
|
||||
unobserveOverflow(element)
|
||||
}
|
||||
}, [measure])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
measure()
|
||||
}, [measure, measurementKey])
|
||||
|
||||
return { ref, node: nodeRef, isOverflowing }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { FloatingTooltip, isTextClipped, useFloatingTooltip } from '@sim/emcn'
|
||||
import { OverflowText } from '@sim/emcn'
|
||||
import type { CodePreview } from '../types'
|
||||
import { CodeHoverCard } from './code-hover-card'
|
||||
|
||||
@@ -41,14 +41,9 @@ export function OverflowSpan({ value, className, codePreview, children }: Overfl
|
||||
|
||||
/** Plain clipped text keeps the platform tooltip behavior unchanged. */
|
||||
function TextOverflowSpan({ value, className, children }: Omit<OverflowSpanProps, 'codePreview'>) {
|
||||
const { state, handlers } = useFloatingTooltip(isTextClipped)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={className} {...handlers}>
|
||||
{children ?? value}
|
||||
</span>
|
||||
<FloatingTooltip label={value} state={state} />
|
||||
</>
|
||||
<OverflowText label={value} className={className}>
|
||||
{children}
|
||||
</OverflowText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,14 +112,13 @@ export function SubBlockRowView({
|
||||
className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize'
|
||||
/>
|
||||
{displayValue !== undefined && (
|
||||
<span
|
||||
<OverflowSpan
|
||||
value={displayValue}
|
||||
className={cn(
|
||||
'flex-1 truncate text-right text-[var(--text-primary)] text-sm',
|
||||
isMonospace && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user