Skip to content

DiffView

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/diff-view.json
See it liveTwo drafts of a verse with a line rewritten and another inserted, the same comparison stacked narrow, and one where nothing changed.→

The question this answers is how did this read before, and how does it read now — a version against the one before it, a proposal against what is there, a file against what is on disk. Not a code review: there is nothing to stage, comment on or accept. It is for looking.

<DiffView
before={previous.body}
after={current.body}
beforeLabel={previous.name}
afterLabel={current.name}
summary={({ added, removed }) => t('diff.summary', { added, removed })}
/>

The alignment is the whole thing, and it is the part that is usually wrong. The obvious way to draw two columns is to filter the change list twice — keep everything that is not added on the left, everything that is not removed on the right. Both columns come out individually correct, and they stop lining up at the first insertion: from there the reader is comparing line 4 against line 3 for the rest of the screen, with nothing looking broken. That is the shape this was taken from, and the defect it exists to fix.

diff-lines pairs the changes instead, so a row is one object with two sides and the columns are the same height by construction rather than by two filters happening to agree.

One scrolling region, not one per column. The second half of the same problem: two scrollers come apart the moment a reader touches one of them, which undoes the pairing at the point it matters most. A row is also as tall as its taller side, so a wrapped line on the left keeps its partner beside it.

A rewritten line is one row, not two. A removal with an insertion right behind it is the commonest edit there is; split across two rows with a gap opposite each, the reader has to work out that these are the same line. It is marked ~ rather than - and +.

Colour is never the message. Every changed line carries a glyph in the gutter — +, -, ~ — so the comparison reads in a greyscale screenshot and for the eighth of men who would otherwise see two tinted greys. The tint is emphasis on top of that.

It stacks below @3xl, on its own width rather than the viewport’s. Two columns each too narrow to hold a line of text answer nothing: every line wraps into three and the comparison is worse than one column would have been. So the pair stacks — the after line directly under the before one, each with its own marker and number — and the header keeps both names on one line with an arrow between them. The breakpoint is a container query because a diff in a side panel is narrow on a wide screen.

Stacks, never hides. This drew the after side with hidden for a while, under a comment that said “stacked”: a narrow reader saw a line marked ~ as rewritten and nothing to compare it with, on a screen that looked finished.

The summary is yours. The component counts; your product says it in words, because a count is a plural and a plural belongs to a language this does not know. Leave summary out and nothing is drawn above the comparison.

Lines, not words. The text here is prose someone wrote and revised, and prose is revised by the line — a word-level diff of a rewritten paragraph is confetti. Where the subject really is word-level, a title or one sentence, compare the two strings yourself.

Props

Read from the types in ui/diff-view.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

PropTypeDefault
sizesm | md | full'md'
beforerequiredstringThe text as it was, drawn on the left.
afterrequiredstringThe text as it is now, drawn on the right.
beforeLabelrequiredstringWhat each side is called - a version name, a date, "on disk".
afterLabelrequiredstringWhat the right side is called; see beforeLabel.
summary(counts: { added: number; removed: number; }) => ReactNodeThe line above the comparison: how much moved. The component counts, the product says it in words - a count is a plural, and a plural belongs to a language the component does not know. Given the two numbers, and left out entirely when there is nothing to say.
numberedbooleantrueNumbers down each side. On by default: a comparison is usually read in order to go and change something, and the number is how the reader finds the place.
copyLabel(side: string) => stringBrings a copy button to each side's header, and names it.
copiedLabelstring''Announced after a successful copy. Required alongside copyLabel.
onCopy(ok: boolean) => voidTold what happened, for a product that wants its own toast.

And every prop of React's HTMLAttributes<HTMLDivElement> except children, onCopy, passed on.

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 17 rules applied and passed, and none failed. The accessibility report has every item.

Left to a person, where axe could not decide: color-contrast.

History

Each release that changed ui/diff-view.tsx - the file a product copies - newest first.

  1. v0.41.0
    • docs read every props table from the component's types ee3b159
  2. v0.25.0
    • fix stack the comparison when narrow, instead of dropping half of it 85d16bb
    • feat markdown through one stylesheet, and code whose colours are yours 5f27eb8