Skip to content

Commit 72363b3

Browse files
authored
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
1 parent 474c105 commit 72363b3

67 files changed

Lines changed: 983 additions & 350 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/emcn-components.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
3232
- **`ChipDatePicker`** — chip-styled date field.
3333
- **`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.
3434
- **`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.
35+
- **`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.
3536

3637
## Modal keyboard defaults
3738

.claude/rules/sim-styling.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`=
5050

5151
Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`.
5252

53+
## Text Overflow
54+
55+
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.
56+
57+
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.
58+
59+
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.
60+
5361
## Font Weight
5462

5563
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.

.cursor/rules/sim-styling.mdc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ Custom font sizes (`apps/sim/tailwind.config.ts`): `text-micro`=10px, `text-xs`=
4444

4545
Icons default `size-[14px]`. Equal h/w → `size-*` (`size-[14px]`, `size-4`), never `h-N w-N`.
4646

47+
## Text Overflow
48+
49+
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.
50+
51+
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.
52+
53+
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.
54+
4755
## Color Tokens
4856

4957
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.

apps/sim/app/f/[token]/public-file-view.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useMemo } from 'react'
4-
import { Chip } from '@sim/emcn'
4+
import { Chip, OverflowText } from '@sim/emcn'
55
import { Download } from '@sim/emcn/icons'
66
import Link from 'next/link'
77
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -85,7 +85,7 @@ export function PublicFileView({
8585
</>
8686
)}
8787
<div className='flex min-w-0 flex-col'>
88-
<span className='truncate text-[14px] text-[var(--text-body)]'>{name}</span>
88+
<OverflowText label={name} className='text-[var(--text-body)] text-sm' />
8989
{provenance ? (
9090
<span className='truncate text-[12px] text-[var(--text-muted)]'>{provenance}</span>
9191
) : null}

apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ReactNode } from 'react'
2-
import { cn } from '@sim/emcn'
2+
import { cn, OverflowText } from '@sim/emcn'
33

44
interface ConversationListItemProps {
55
title: string
@@ -23,7 +23,7 @@ export function ConversationListItem({
2323
const showStatusDot = isActive || isUnread
2424
return (
2525
<div className={cn('flex w-full min-w-0 items-center gap-2', className)}>
26-
<span className={cn('min-w-0 flex-1 truncate', titleClassName)}>{title}</span>
26+
<OverflowText label={title} className={cn('flex-1', titleClassName)} />
2727
{showStatusDot && (
2828
<span
2929
aria-hidden='true'
Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,3 @@
11
'use client'
22

3-
import type React from 'react'
4-
import { memo } from 'react'
5-
import { cn, FloatingTooltip, isTextClipped, useFloatingTooltip, useIsOverflowing } from '@sim/emcn'
6-
7-
interface FloatingOverflowTextProps {
8-
/** Full text shown in the tooltip and used as the default visible content. */
9-
label: string
10-
/** Optional custom visible content (e.g. highlighted text); defaults to `label`. */
11-
children?: React.ReactNode
12-
className?: string
13-
/** Forces the tooltip even when the text is not visually clipped (e.g. content truncated upstream). */
14-
showWhen?: boolean
15-
}
16-
17-
/**
18-
* Truncating text that fades its clipped edge and reveals the full value in a
19-
* pointer-reactive floating tooltip on hover or focus.
20-
*/
21-
export const FloatingOverflowText = memo(function FloatingOverflowText({
22-
label,
23-
children,
24-
className,
25-
showWhen,
26-
}: FloatingOverflowTextProps) {
27-
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>()
28-
const { state, handlers } = useFloatingTooltip(() => {
29-
const element = node.current
30-
if (!element || label.length === 0) return false
31-
return Boolean(showWhen) || isTextClipped(element)
32-
})
33-
34-
return (
35-
<>
36-
<span
37-
ref={textRef}
38-
className={cn(
39-
'min-w-0',
40-
isOverflowing &&
41-
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] hover:[mask-image:none] focus-visible:[mask-image:none]',
42-
className
43-
)}
44-
{...handlers}
45-
>
46-
{children ?? label}
47-
</span>
48-
<FloatingTooltip label={label} state={state} />
49-
</>
50-
)
51-
})
3+
export { OverflowText as FloatingOverflowText } from '@sim/emcn'

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
DropdownMenuItem,
2525
DropdownMenuTrigger,
2626
FloatingTooltip,
27+
OverflowText,
28+
overflowTextFadeClass,
2729
POPOVER_ANIMATION_CLASSES,
2830
Popover,
2931
PopoverAnchor,
@@ -664,7 +666,7 @@ function BreadcrumbLocationItem({
664666
<span className='size-1.5 rounded-full bg-[var(--text-muted)]' />
665667
)}
666668
</span>
667-
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
669+
<OverflowText label={label} className='flex-1 text-left' />
668670
</>
669671
)
670672

@@ -702,8 +704,9 @@ const BreadcrumbLabel = memo(
702704
ref={ref}
703705
className={cn(
704706
'min-w-0 truncate text-[var(--text-body)]',
707+
isOverflowing && overflowTextFadeClass,
705708
isOverflowing &&
706-
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] group-hover:[mask-image:none] group-focus-visible:[mask-image:none]'
709+
'group-hover:[-webkit-mask-image:none] group-hover:[mask-image:none] group-focus-visible:[-webkit-mask-image:none] group-focus-visible:[mask-image:none]'
707710
)}
708711
>
709712
{label}

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ afterEach(() => {
3535
})
3636

3737
describe('SortDropdown', () => {
38-
it('renders long option labels in the truncatable span between both icons', () => {
38+
it('renders long option labels in the overflow span between both icons', () => {
3939
act(() => {
4040
root.render(
4141
<SortDropdown
@@ -63,7 +63,8 @@ describe('SortDropdown', () => {
6363
const label = item?.querySelector('span')
6464
expect(label).not.toBeNull()
6565
expect(label).toHaveTextContent(LONG_COLUMN_LABEL)
66-
expect(label).toHaveClass('min-w-0', 'block', 'truncate')
66+
expect(label).toHaveClass('min-w-0', 'block', 'overflow-hidden', 'text-clip')
67+
expect(label).not.toHaveClass('truncate')
6768
expect(item?.querySelector('[data-testid="column-icon"]')).not.toBeNull()
6869
expect(item?.querySelectorAll('svg')).toHaveLength(2)
6970
})

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
232232
active={search.highlightedTagIndex === i}
233233
className='max-w-[280px] shrink-0'
234234
>
235-
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block truncate'>
235+
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block'>
236236
{tag.label}: {tag.value}
237237
</FloatingOverflowText>
238238
</Chip>
@@ -330,7 +330,7 @@ export const SortDropdown = memo(function SortDropdown({
330330
}}
331331
>
332332
{Icon && <Icon />}
333-
<FloatingOverflowText label={option.label} className='block truncate' />
333+
<FloatingOverflowText label={option.label} className='block' />
334334
{DirectionIcon && (
335335
<DirectionIcon className='ml-auto size-[12px] text-[var(--text-tertiary)]' />
336336
)}

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Folder,
1212
FolderPlus,
1313
Loader,
14+
OverflowText,
1415
Pencil,
1516
Plus,
1617
Trash,
@@ -1968,8 +1969,13 @@ export function Files() {
19681969
multiSelect
19691970
multiSelectValues={typeFilter}
19701971
onMultiSelectChange={setTypeFilter}
1972+
overlayLabel={typeDisplayLabel}
19711973
overlayContent={
1972-
<span className='truncate text-[var(--text-primary)]'>{typeDisplayLabel}</span>
1974+
<OverflowText
1975+
label={typeDisplayLabel}
1976+
className='block w-full text-[var(--text-primary)]'
1977+
tooltipEnabled={false}
1978+
/>
19731979
}
19741980
showAllOption
19751981
allOptionLabel='All'
@@ -1987,8 +1993,13 @@ export function Files() {
19871993
multiSelect
19881994
multiSelectValues={sizeFilter}
19891995
onMultiSelectChange={setSizeFilter}
1996+
overlayLabel={sizeDisplayLabel}
19901997
overlayContent={
1991-
<span className='truncate text-[var(--text-primary)]'>{sizeDisplayLabel}</span>
1998+
<OverflowText
1999+
label={sizeDisplayLabel}
2000+
className='block w-full text-[var(--text-primary)]'
2001+
tooltipEnabled={false}
2002+
/>
19922003
}
19932004
showAllOption
19942005
allOptionLabel='All'

0 commit comments

Comments
 (0)