Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions packages/web/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ Object.defineProperty(window, 'matchMedia', {
})),
});

// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock ResizeObserver. A function expression, not an arrow: callers construct it.
global.ResizeObserver = vi.fn().mockImplementation(function () {
return {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
};
});

// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
Expand Down
26 changes: 24 additions & 2 deletions packages/web/src/components/charts/TrafficLightChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useImperativeHandle } from 'react';
// @ts-expect-error -- d3 has no type declarations in this project
import * as d3 from 'd3';
import { fitToWidth } from '@/lib/fitToWidth';
import { contrastColor, legendCategories, legendMarginRight } from './chartConfigs';
import type { ChartCategory, ChartPalette, ChecklistChartConfig } from './chartConfigs';

Expand All @@ -31,6 +32,24 @@ interface TrafficLightChartProps {
// sizing alone made cells balloon to ~70px when only a few reviews existed.
const MAX_CELL_SIZE = 32;

// Row labels are study names; uncapped, a long one swallows the heatmap.
const MAX_LABEL_WIDTH = 240;
const LABEL_GAP = 20;

/** Shortens each label in a d3 text selection until it fits maxWidth, keeping the full name as a hover title. */
function fitLabels(selection: d3.Selection, maxWidth: number): void {
selection.each(function (this: SVGTextElement) {
const full = this.textContent ?? '';
const fitted = fitToWidth(full, maxWidth, candidate => {
this.textContent = candidate;
return this.getComputedTextLength();
});
if (fitted === full) return;
this.textContent = fitted;
d3.select(this).append('title').text(full);
});
}

const CAPTION_LINE_HEIGHT = 15;

export function TrafficLightChart({
Expand Down Expand Up @@ -124,7 +143,9 @@ export function TrafficLightChart({
});
tempSvg.remove();

setDynamicMarginLeft(Math.max(150, Math.ceil(maxLabelWidth + 20)));
setDynamicMarginLeft(
Math.max(150, Math.ceil(Math.min(maxLabelWidth, MAX_LABEL_WIDTH)) + LABEL_GAP),
);
}, [data]);

// D3 imperative draw
Expand Down Expand Up @@ -200,7 +221,8 @@ export function TrafficLightChart({
.attr('font-weight', '500')
.attr('fill', '#374151')
.attr('dominant-baseline', 'middle')
.text((d: TrafficLightDataItem) => d.label);
.text((d: TrafficLightDataItem) => d.label)
.call(fitLabels, m.left - LABEL_GAP);

// Traffic light cells
const cellGroup = svg.append('g');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export function StudyCardHeader({
value={studyName}
onCommit={handleNameChange}
showEditIcon
truncate
ariaLabel='Rename study'
className='text-foreground font-medium'
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { StudyInfo, MemberEntry } from '@/stores/projectStore';
import { getCitationLine, sortStudyPdfs } from '../study-utils';
import { MemberAvatar, memberDisplayName } from '../MemberAvatar';
import type { AssignSheetScope } from '../ProjectContext';
import { TruncatedText } from '@/components/ui/truncated-text';
import { ReviewerPicker } from './ReviewerPicker';
import { AutoFillSettings, evenShares } from './AutoFillSettings';
import {
Expand Down Expand Up @@ -317,7 +318,7 @@ export function ReviewerAssignment({
className='hover:bg-muted/40 grid grid-cols-1 items-center gap-x-2 gap-y-2 px-4 py-2 sm:grid-cols-[1fr_9rem_9rem_1.5rem]'
>
<div className='min-w-0'>
<p className='text-foreground truncate text-sm font-medium'>{studyName}</p>
<TruncatedText text={studyName} className='text-foreground text-sm font-medium' />
{citation && <p className='text-muted-foreground truncate text-xs'>{citation}</p>}
</div>
<div className='grid grid-cols-[1fr_1fr_1.5rem] gap-2 sm:contents'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getStatusLabel,
getStatusStyle,
} from '@corates/shared/checklists';
import { TruncatedText } from '@/components/ui/truncated-text';
import { ChangeOutcomeDialog } from '../ChangeOutcomeDialog';
import { PreviousReviewersView } from './PreviousReviewersView';
import { CompletedOutcomeRow } from './CompletedOutcomeRow';
Expand Down Expand Up @@ -114,7 +115,7 @@ export function CompletedStudyRow({
)}

<div className='min-w-0 flex-1'>
<span className='text-foreground block truncate font-medium'>{study.name}</span>
<TruncatedText text={study.name} className='text-foreground font-medium' />
{citationLine && (
<p
className='text-muted-foreground w-fit cursor-text truncate text-xs select-text'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { useMemo } from 'react';
import { CHECKLIST_STATUS } from '@corates/shared/checklists';
import { ScoreTag, ScoreTooltip } from '@/components/checklist/ScoreTag';
import { TruncatedText } from '@/components/ui/truncated-text';
import type { StudyInfo } from '@/stores/projectStore';
import { OutputCard, OutputCardHeader, OutputCardPlate } from './OutputCard';

Expand Down Expand Up @@ -149,8 +150,8 @@ export function AMSTAR2ResultsTable({ studies, tableNumber }: AMSTAR2ResultsTabl
<tbody>
{studyScores.map(item => (
<tr key={item.studyId} className='border-t border-[#f2f4f7] hover:bg-[#fcfcfd]'>
<td className='px-4 py-3 text-[13.5px] font-normal whitespace-nowrap text-[#101828]'>
{item.studyName}
<td className='px-4 py-3 text-[13.5px] font-normal text-[#101828]'>
<TruncatedText text={item.studyName} className='max-w-[22rem]' />
</td>
<td className='px-4 py-3 text-[13.5px] whitespace-nowrap'>
<ScoreTag currentScore={item.score} checklistType='AMSTAR2' showRatingOnly />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,6 @@ function slugify(value: string): string {
);
}

function truncateLabel(name: string): string {
return name.length > 20 ? name.slice(0, 20) + '...' : name;
}

interface ChartItem {
id: string;
label: string;
Expand Down Expand Up @@ -165,7 +161,7 @@ export function ChartSection({ studies }: ChartSectionProps) {
if (checklist.type === 'AMSTAR2') {
amstarData.push({
id: `${study.id}-${checklist.id}`,
label: truncateLabel(study.name),
label: study.name,
values: AMSTAR2_CHART_CONFIG.columns.map(c => answersObj[c.id] ?? ''),
});
continue;
Expand All @@ -178,7 +174,7 @@ export function ChartSection({ studies }: ChartSectionProps) {
const items = byOutcome.get(outcomeKey) ?? [];
items.push({
id: `${study.id}-${checklist.id}`,
label: truncateLabel(study.name),
label: study.name,
values: tool.config.columns.map(c => answersObj[c.id] ?? ''),
});
byOutcome.set(outcomeKey, items);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* ResultsTable — styled data table used inside overview table cards.
*/

import { TruncatedText } from '@/components/ui/truncated-text';
import { JUDGEMENT_PILLS } from './judgementPills';

function JudgementPill({ value }: { value: string }) {
Expand Down Expand Up @@ -56,8 +57,9 @@ export function ResultsTable({ columns, rows }: ResultsTableProps) {
<tbody>
{rows.map(row => (
<tr key={row.id} className='border-t border-[#f2f4f7] hover:bg-[#fcfcfd]'>
<td className='px-4 py-3 text-[13.5px] font-normal whitespace-nowrap text-[#101828]'>
{row.studyName}
<td className='px-4 py-3 text-[13.5px] font-normal text-[#101828]'>
{/* Capped so a long name cannot push the judgement columns off-screen. */}
<TruncatedText text={row.studyName} className='max-w-[22rem]' />
</td>
{columns.map(column => (
<td key={column.id} className='px-4 py-3'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { ChecklistGroup } from '@corates/shared/checklists';
import { getChecklistMetadata } from '@/checklist-registry';
import { PdfListItem } from '@/components/pdf/PdfListItem';
import { project } from '@/project';
import { TruncatedText } from '@/components/ui/truncated-text';
import { ChangeOutcomeDialog } from '../ChangeOutcomeDialog';
import { useCanReconcileChecklists } from './useCanReconcileChecklists';
import { ReconcileStatusTag } from './ReconcileStatusTag';
Expand Down Expand Up @@ -134,7 +135,7 @@ export function ReconcileStudyRow({
)}

<div className='min-w-0 flex-1'>
<span className='text-foreground block truncate font-medium'>{study.name}</span>
<TruncatedText text={study.name} className='text-foreground font-medium' />
{citationLine && (
<p
className='text-muted-foreground w-fit cursor-text truncate text-xs select-text'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function StudySheetBody({ study, ...appraisalProps }: StudySheetBodyProps) {
{...dropProps}
>
<SheetHeader>
<SheetTitle className='truncate'>
<SheetTitle className='min-w-0'>
<InlineEdit
key={study.name}
value={study.name || 'Untitled study'}
Expand All @@ -109,7 +109,9 @@ function StudySheetBody({ study, ...appraisalProps }: StudySheetBodyProps) {
}
}}
showEditIcon
truncate
ariaLabel='Rename study'
className='leading-tight'
/>
</SheetTitle>
<SheetDescription>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { getChecklistMetadata, CHECKLIST_TYPES } from '@/checklist-registry';
import { TruncatedText } from '@/components/ui/truncated-text';
import { PdfListItem } from '@/components/pdf/PdfListItem';
import { ChecklistForm } from './ChecklistForm';
import { getStatusLabel, getStatusStyle } from '@corates/shared/checklists';
Expand Down Expand Up @@ -186,7 +187,7 @@ export function TodoStudyRow({

<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<span className='text-foreground truncate font-medium'>{study.name}</span>
<TruncatedText text={study.name} className='text-foreground min-w-0 font-medium' />
</div>
{citationLine && (
<p
Expand Down
49 changes: 49 additions & 0 deletions packages/web/src/components/ui/__tests__/truncated-text.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, act } from '@testing-library/react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { TruncatedText } from '@/components/ui/truncated-text';

/** jsdom does no layout, so the widths truncation depends on are stubbed. */
function stubWidths(scrollWidth: number, clientWidth: number) {
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
get: () => scrollWidth,
});
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get: () => clientWidth,
});
}

afterEach(() => {
Reflect.deleteProperty(HTMLElement.prototype, 'scrollWidth');
Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth');
});

function renderText(text: string) {
return render(
<TooltipProvider>
<TruncatedText text={text} />
</TooltipProvider>,
);
}

describe('TruncatedText', () => {
it('leaves a name that fits without a tooltip', () => {
stubWidths(100, 100);
renderText('Trial A');

expect(screen.getByText('Trial A')).not.toHaveAttribute('data-slot', 'tooltip-trigger');
});

it('offers the full name in a tooltip once it is clipped', async () => {
stubWidths(900, 300);
renderText('A long study name');

// The measurement lands in an effect, so the tooltip is wired a pass later.
await act(async () => {});

expect(screen.getByText('A long study name')).toHaveAttribute('data-slot', 'tooltip-trigger');
});
});
47 changes: 33 additions & 14 deletions packages/web/src/components/ui/inline-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@

import * as React from 'react';
import { PencilIcon } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { NAME_TOOLTIP_DELAY_MS } from '@/components/ui/truncated-text';
import { useIsTruncated } from '@/hooks/useIsTruncated';
import { cn } from '@/lib/utils';

interface InlineEditProps {
Expand All @@ -23,6 +26,8 @@ interface InlineEditProps {
rows?: number;
disabled?: boolean;
showEditIcon?: boolean;
/** Clips the preview to one line, with the full value in a tooltip. Single-line only. */
truncate?: boolean;
/** Styles applied to both the preview text and the editing field. */
className?: string;
ariaLabel?: string;
Expand All @@ -36,12 +41,14 @@ function InlineEdit({
rows = 1,
disabled = false,
showEditIcon = false,
truncate = false,
className,
ariaLabel,
}: InlineEditProps) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(value);
const fieldRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [previewRef, isTruncated] = useIsTruncated(value);

const startEditing = () => {
if (disabled) return;
Expand Down Expand Up @@ -102,27 +109,39 @@ function InlineEdit({
/>;
}

const preview = (
<button
ref={previewRef}
type='button'
onClick={startEditing}
disabled={disabled}
className={cn(
'cursor-text text-left',
multiline && 'w-full',
truncate && 'min-w-0 truncate',
!value && 'text-muted-foreground/70',
disabled && 'cursor-not-allowed opacity-50',
className,
)}
>
{value || placeholder}
</button>
);

return (
<span
className={cn(
'group gap-1',
multiline ? 'flex w-full items-start' : 'inline-flex items-center',
truncate && 'max-w-full',
)}
>
<button
type='button'
onClick={startEditing}
disabled={disabled}
className={cn(
'cursor-text text-left',
multiline && 'w-full',
!value && 'text-muted-foreground/70',
disabled && 'cursor-not-allowed opacity-50',
className,
)}
>
{value || placeholder}
</button>
{truncate && isTruncated ?
<Tooltip delayDuration={NAME_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>{preview}</TooltipTrigger>
<TooltipContent className='max-w-sm'>{value}</TooltipContent>
</Tooltip>
: preview}
{showEditIcon && !disabled && (
<button
type='button'
Expand Down
Loading
Loading