Skip to content

Commit a49e4cb

Browse files
committed
Replace native title tooltip with styled hover badge for attribution
Show a colored badge (author dot + name + relative time) on hover instead of the browser's plain title tooltip. Badge uses a solid white background with the author's color on border and text, and appears below the hovered node. CSS is injected once from AstRenderer rather than per-Node.
1 parent e3a1b83 commit a49e4cb

2 files changed

Lines changed: 81 additions & 39 deletions

File tree

hub-client/src/components/render/ReactAstDebugRenderer.test.tsx

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { describe, it, expect } from 'vitest';
1010
import React from 'react';
11-
import { render, screen } from '@testing-library/react';
11+
import { render } from '@testing-library/react';
1212
import { Ast } from './ReactAstDebugRenderer';
1313
import type { PandocAST } from './ReactAstDebugRenderer';
1414
import { NodeAttributionContext } from './ReactAstDebugRenderer';
@@ -30,8 +30,8 @@ function makeAstJson(opts?: { sourceInfoId?: number }): string {
3030
}
3131

3232
describe('Node rendering with attribution', () => {
33-
it('renders with attribution color and title tooltip when context is provided', () => {
34-
const mockGetNodeAttribution = (sourceInfoId: number): NodeAttribution | null => ({
33+
it('renders colored badge with author name on attributed nodes', () => {
34+
const mockGetNodeAttribution = (_sourceInfoId: number): NodeAttribution | null => ({
3535
actor: 'actor1',
3636
time: 1700000000000,
3737
color: '#E91E63',
@@ -42,44 +42,48 @@ describe('Node rendering with attribution', () => {
4242

4343
const { container } = render(
4444
<NodeAttributionContext.Provider value={{ getNodeAttribution: mockGetNodeAttribution }}>
45-
<Ast
46-
astJson={astJson}
47-
setAst={() => {}}
48-
/>
45+
<Ast astJson={astJson} setAst={() => {}} />
4946
</NodeAttributionContext.Provider>
5047
);
5148

52-
// Find the Str node's span — it should have the attribution color
53-
const strSpan = container.querySelector('[title]');
54-
expect(strSpan).not.toBeNull();
55-
expect(strSpan!.getAttribute('title')).toContain('Alice');
56-
expect((strSpan as HTMLElement).style.color).toBe('rgb(233, 30, 99)'); // #E91E63
49+
// Badge element should exist with author name
50+
const badge = container.querySelector('.q2-attr-badge');
51+
expect(badge).not.toBeNull();
52+
expect(badge!.textContent).toContain('Alice');
53+
54+
// Wrapper should have the attribution color
55+
const wrapper = container.querySelector('.q2-attr-wrap');
56+
expect(wrapper).not.toBeNull();
57+
expect((wrapper as HTMLElement).style.color).toBe('rgb(233, 30, 99)'); // #E91E63
5758
});
5859

59-
it('renders identically to current behavior when attribution context is null', () => {
60+
it('renders without badge when attribution context is null', () => {
6061
const astJson = makeAstJson({ sourceInfoId: 42 });
6162

62-
const { container: withoutCtx } = render(
63+
const { container } = render(
6364
<Ast astJson={astJson} setAst={() => {}} />
6465
);
6566

66-
// Should render without any attribution styling — no title attribute on Str
67-
const strSpans = withoutCtx.querySelectorAll('span');
68-
const strSpan = Array.from(strSpans).find(s => s.textContent?.includes('hello'));
67+
// No badge or attribution wrapper
68+
expect(container.querySelector('.q2-attr-badge')).toBeNull();
69+
expect(container.querySelector('.q2-attr-wrap')).toBeNull();
70+
71+
// Str still renders
72+
const strSpan = Array.from(container.querySelectorAll('span')).find(
73+
s => s.textContent?.includes('hello'),
74+
);
6975
expect(strSpan).toBeTruthy();
70-
// No title attribute when no attribution context
71-
expect(strSpan!.hasAttribute('title')).toBe(false);
7276
});
7377

74-
it('renders without attribution styling when node has no s field', () => {
78+
it('renders without badge when node has no s field', () => {
7579
const mockGetNodeAttribution = (_id: number): NodeAttribution | null => ({
7680
actor: 'actor1',
7781
time: 1700000000000,
7882
color: '#E91E63',
7983
name: 'Alice',
8084
});
8185

82-
// No sourceInfoId set on the Str node
86+
// No sourceInfoId on the Str node
8387
const astJson = makeAstJson();
8488

8589
const { container } = render(
@@ -88,10 +92,7 @@ describe('Node rendering with attribution', () => {
8892
</NodeAttributionContext.Provider>
8993
);
9094

91-
// Str span should render but without attribution (no title)
92-
const strSpans = container.querySelectorAll('span');
93-
const strSpan = Array.from(strSpans).find(s => s.textContent?.includes('hello'));
94-
expect(strSpan).toBeTruthy();
95-
expect(strSpan!.hasAttribute('title')).toBe(false);
95+
// No badge since node has no source info
96+
expect(container.querySelector('.q2-attr-badge')).toBeNull();
9697
});
9798
});

hub-client/src/components/render/ReactAstDebugRenderer.tsx

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,7 @@ const AstRenderer = ({ ast, onNavigateToDocument, setAst }: {
592592
// Otherwise, allow any external provider to pass through.
593593
return nodeAttributionValue ? (
594594
<NodeAttributionContext.Provider value={nodeAttributionValue}>
595+
<style>{attributionStyles}</style>
595596
{tree}
596597
</NodeAttributionContext.Provider>
597598
) : tree;
@@ -627,6 +628,51 @@ function formatRelativeTime(timestamp: number): string {
627628
return `${diffDay}d ago`;
628629
}
629630

631+
/** Styled tooltip that appears on hover, colored to match the author */
632+
function AttributionBadge({ attr }: { attr: { name: string; time: number; color: string } }) {
633+
return <span className="q2-attr-badge" style={{
634+
'--attr-color': attr.color,
635+
} as React.CSSProperties}>
636+
<span className="q2-attr-badge-dot" style={{ backgroundColor: attr.color }} />
637+
{attr.name} <span className="q2-attr-badge-time">{formatRelativeTime(attr.time)}</span>
638+
</span>;
639+
}
640+
641+
/** Hover styles for attribution badges — rendered once per AstRenderer */
642+
const attributionStyles = `
643+
.q2-attr-wrap { position: relative; }
644+
.q2-attr-badge {
645+
display: none;
646+
position: absolute;
647+
bottom: -20px;
648+
left: 0;
649+
z-index: 10;
650+
font-size: 10px;
651+
line-height: 1;
652+
white-space: nowrap;
653+
padding: 2px 6px;
654+
border-radius: 3px;
655+
background: #fff;
656+
border: 1px solid var(--attr-color);
657+
color: var(--attr-color);
658+
font-weight: 600;
659+
pointer-events: none;
660+
}
661+
.q2-attr-badge-dot {
662+
display: inline-block;
663+
width: 6px;
664+
height: 6px;
665+
border-radius: 50%;
666+
margin-right: 3px;
667+
vertical-align: middle;
668+
}
669+
.q2-attr-badge-time {
670+
font-weight: 400;
671+
opacity: 0.7;
672+
}
673+
.q2-attr-wrap:hover > .q2-attr-badge { display: inline-block; }
674+
`;
675+
630676
const Node = ({
631677
node,
632678
onNavigateToDocument,
@@ -640,18 +686,11 @@ const Node = ({
640686
const registry = registries?.registry ?? componentRegistry;
641687
const attributionCtx = useContext(NodeAttributionContext);
642688

643-
// Extract per-node attribution styling
689+
// Resolve attribution for this node
644690
const sourceInfoId = (node as { s?: number }).s;
645-
let attributionStyle: React.CSSProperties | undefined;
646-
let attributionTitle: string | undefined;
647-
648-
if (sourceInfoId != null && attributionCtx) {
649-
const attr = attributionCtx.getNodeAttribution(sourceInfoId);
650-
if (attr) {
651-
attributionStyle = { color: attr.color };
652-
attributionTitle = `${attr.name}, ${formatRelativeTime(attr.time)}`;
653-
}
654-
}
691+
const attr = (sourceInfoId != null && attributionCtx)
692+
? attributionCtx.getNodeAttribution(sourceInfoId)
693+
: null;
655694

656695
// Check if it's a Block type by looking at common block tags
657696
const blockTypes = ['Para', 'Plain', 'Header', 'CodeBlock', 'BulletList', 'OrderedList', 'BlockQuote', 'Div', 'HorizontalRule', 'RawBlock', 'Figure'];
@@ -662,7 +701,8 @@ const Node = ({
662701
if (!BlockComponent) {
663702
return <div style={blockStyle}><strong>Block wrapper not registered</strong></div>;
664703
}
665-
return <div style={attributionStyle} title={attributionTitle}>
704+
return <div className={attr ? 'q2-attr-wrap' : undefined} style={attr ? { color: attr.color } : undefined}>
705+
{attr && <AttributionBadge attr={attr} />}
666706
<BlockComponent
667707
node={node as BlockNode}
668708
onNavigateToDocument={onNavigateToDocument}
@@ -674,7 +714,8 @@ const Node = ({
674714
if (!InlineComponent) {
675715
return <span style={inlineStyle}><strong>Inline wrapper not registered</strong></span>;
676716
}
677-
return <span style={attributionStyle} title={attributionTitle}>
717+
return <span className={attr ? 'q2-attr-wrap' : undefined} style={attr ? { color: attr.color } : undefined}>
718+
{attr && <AttributionBadge attr={attr} />}
678719
<InlineComponent
679720
node={node as InlineNode}
680721
onNavigateToDocument={onNavigateToDocument}

0 commit comments

Comments
 (0)