Skip to content

Commit bc47fd6

Browse files
committed
Add GitHub-style anchor links to chart headers
- Create ChartHeader component with hoverable anchor icons - Icon appears after heading text, hidden on desktop until hover - Always visible on mobile for touch accessibility - Clicking copies URL with hash to clipboard and updates browser URL - Uses same ID generation algorithm as TableOfContents for compatibility - Update all chart components to use ChartHeader for ToC items
1 parent d036e1f commit bc47fd6

13 files changed

Lines changed: 123 additions & 41 deletions

src/App.jsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import UnderstandingRatingsGrid from "./components/UnderstandingRatingsGrid";
2323
import DeviceSatisfactionGrid from "./components/DeviceSatisfactionGrid";
2424
import TableOfContents from "./components/TableOfContents";
2525
import RespondentIcon from "./components/RespondentIcon";
26+
import ChartHeader from "./components/ChartHeader";
2627
import {
2728
header,
2829
sidebar,
@@ -1826,10 +1827,7 @@ function App() {
18261827
<div className="flex-1">
18271828
<div className="flex items-center justify-between">
18281829
<div>
1829-
<h3 className="text-lg font-semibold text-nodered-gray-700">
1830-
What helps you learn/troubleshoot
1831-
Node-RED?
1832-
</h3>
1830+
<ChartHeader title="What helps you learn/troubleshoot Node-RED?" />
18331831
</div>
18341832
{/* Respondent Count Badge - matching qualitative style */}
18351833
{sectionCounts.section1 && (

src/components/ChartHeader.jsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useState, useCallback, memo } from 'react';
2+
3+
/**
4+
* Generate a stable section ID from text using the same algorithm as TableOfContents.
5+
* This ensures IDs match between ChartHeader and ToC navigation.
6+
*/
7+
const generateSectionId = (text) => {
8+
if (!text) return '';
9+
const cleanText = text.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
10+
return `section-${cleanText}`;
11+
};
12+
13+
/**
14+
* Link icon SVG (GitHub octicon-link style)
15+
*/
16+
const LinkIcon = ({ className }) => (
17+
<svg
18+
className={className}
19+
viewBox="0 0 16 16"
20+
version="1.1"
21+
width="16"
22+
height="16"
23+
aria-hidden="true"
24+
>
25+
<path
26+
fill="currentColor"
27+
d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"
28+
/>
29+
</svg>
30+
);
31+
32+
/**
33+
* ChartHeader component with GitHub-style anchor link.
34+
*
35+
* Features:
36+
* - Generates stable section IDs matching TableOfContents algorithm
37+
* - Shows anchor icon on hover (desktop) or always visible (mobile)
38+
* - Clicking anchor copies URL to clipboard and updates browser hash
39+
* - Brief visual feedback on copy
40+
*/
41+
const ChartHeader = ({ title, compact = false, className }) => {
42+
const [copied, setCopied] = useState(false);
43+
44+
const sectionId = generateSectionId(title);
45+
46+
const handleAnchorClick = useCallback((e) => {
47+
e.preventDefault();
48+
49+
if (!sectionId) return;
50+
51+
// Update browser URL hash
52+
window.history.pushState(null, '', `#${sectionId}`);
53+
54+
// Copy full URL to clipboard
55+
const fullUrl = `${window.location.origin}${window.location.pathname}#${sectionId}`;
56+
navigator.clipboard.writeText(fullUrl).then(() => {
57+
setCopied(true);
58+
setTimeout(() => setCopied(false), 1500);
59+
}).catch(() => {
60+
// Fallback: just update the hash without clipboard notification
61+
});
62+
}, [sectionId]);
63+
64+
// Determine header classes based on compact mode or custom className
65+
const headerClasses = className || (compact
66+
? "text-sm font-medium text-nodered-gray-700"
67+
: "text-lg font-semibold text-nodered-gray-700"
68+
);
69+
70+
return (
71+
<h3
72+
id={sectionId}
73+
className={`group flex items-center gap-2 ${headerClasses}`}
74+
>
75+
{/* Title text */}
76+
<span>{title}</span>
77+
78+
{/* Anchor link - positioned after the heading, visible on hover (desktop) or always (mobile) */}
79+
<a
80+
href={`#${sectionId}`}
81+
onClick={handleAnchorClick}
82+
className="opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-gray-400 hover:text-nodered-red"
83+
aria-label={`Link to ${title}`}
84+
title={copied ? "Copied!" : "Copy link"}
85+
>
86+
<LinkIcon className={`w-4 h-4 ${copied ? 'text-green-500' : ''}`} />
87+
</a>
88+
</h3>
89+
);
90+
};
91+
92+
export default memo(ChartHeader);

src/components/ChoroplethMap.jsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { feature } from 'topojson-client';
44
import RespondentIcon from './RespondentIcon';
55
import { getTooltipPosition, useHideTooltipOnScroll } from '../utils/tooltip-utils';
66
import Tooltip from './Tooltip';
7+
import ChartHeader from './ChartHeader';
78

89
const ChoroplethMap = ({ questionId, questionTitle, filters, _color, wasmService }) => {
910
if (import.meta.env.DEV) console.log('=== ChoroplethMap RENDER ===', { questionId, hasWasmService: !!wasmService });
@@ -122,7 +123,7 @@ const ChoroplethMap = ({ questionId, questionTitle, filters, _color, wasmService
122123
</div>
123124
<div className="flex-1 flex flex-col overflow-hidden">
124125
<div className="px-4 py-3 border-b border-gray-200 bg-white">
125-
<h3 className="text-lg font-semibold text-nodered-gray-700">{questionTitle}</h3>
126+
<ChartHeader title={questionTitle} />
126127
</div>
127128
<div className="p-6">
128129
<div className="text-gray-500">Loading...</div>
@@ -150,7 +151,7 @@ const ChoroplethMap = ({ questionId, questionTitle, filters, _color, wasmService
150151
</div>
151152
<div className="flex-1 flex flex-col overflow-hidden">
152153
<div className="px-4 py-3 border-b border-gray-200 bg-white">
153-
<h3 className="text-lg font-semibold text-nodered-gray-700">{questionTitle}</h3>
154+
<ChartHeader title={questionTitle} />
154155
</div>
155156
<div className="p-6">
156157
<div className="text-red-500">Error loading data: {error}</div>
@@ -272,9 +273,7 @@ const ChoroplethMap = ({ questionId, questionTitle, filters, _color, wasmService
272273
<div className="flex-1">
273274
<div className="flex items-center justify-between">
274275
<div>
275-
<h3 className="text-lg font-semibold text-nodered-gray-700">
276-
{questionTitle}
277-
</h3>
276+
<ChartHeader title={questionTitle} />
278277
</div>
279278
{/* Total Respondents Badge */}
280279
<div className="flex items-center gap-1 text-sm flex-shrink-0">

src/components/DesignChangesRatingsGrid.jsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getRatingScheme } from '../utils/colorPalette';
33
import RespondentIcon from './RespondentIcon';
44
import { getTooltipPosition, useHideTooltipOnScroll } from '../utils/tooltip-utils';
55
import Tooltip from './Tooltip';
6+
import ChartHeader from './ChartHeader';
67

78
const DESIGN_CHANGE_QUESTIONS = [
89
{ id: '089k8A', name: 'Node-RED branding (logo, website, forum)' },
@@ -161,9 +162,7 @@ const DesignChangesRatingsGrid = ({ filters = {}, wasmService }) => {
161162
<div className="flex-1">
162163
{/* Header */}
163164
<div className="px-4 py-3 border-b border-gray-200 bg-white">
164-
<h3 className="text-lg font-semibold text-nodered-gray-700">
165-
How would you feel about potential changes to?
166-
</h3>
165+
<ChartHeader title="How would you feel about potential changes to?" />
167166
</div>
168167

169168
{/* Questions Grid */}

src/components/DeviceSatisfactionGrid.jsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getRatingScheme } from '../utils/colorPalette';
33
import RespondentIcon from './RespondentIcon';
44
import { getTooltipPosition, useHideTooltipOnScroll } from '../utils/tooltip-utils';
55
import Tooltip from './Tooltip';
6+
import ChartHeader from './ChartHeader';
67

78
const DEVICE_QUESTIONS = [
89
{ id: 'bepze7', name: 'Desktop/Laptop', colorScheme: 'yellow' },
@@ -161,9 +162,7 @@ const DeviceSatisfactionGrid = ({ filters = {}, wasmService }) => {
161162
<div className="flex-1">
162163
{/* Header */}
163164
<div className="px-4 py-3 border-b border-gray-200 bg-white">
164-
<h3 className="text-lg font-semibold text-nodered-gray-700">
165-
Satisfaction ratings by device type?
166-
</h3>
165+
<ChartHeader title="Satisfaction ratings by device type?" />
167166
</div>
168167

169168
{/* Questions Grid */}

src/components/HorizontalRatingsChart.jsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ORDINAL_ORDERS } from '../utils/ordinalOrdering';
44
import RespondentIcon from './RespondentIcon';
55
import { getTooltipPosition, useHideTooltipOnScroll } from '../utils/tooltip-utils';
66
import Tooltip from './Tooltip';
7+
import ChartHeader from './ChartHeader';
78

89
const HorizontalRatingsChart = ({ questionId, questionTitle, filters = {}, _showRatingScale = false, _ratingScale = 7, wasmService }) => {
910
const [data, setData] = useState(null);
@@ -343,9 +344,7 @@ const HorizontalRatingsChart = ({ questionId, questionTitle, filters = {}, _show
343344
<div className="flex-1">
344345
<div className="flex items-center justify-between">
345346
<div>
346-
<h3 className="text-lg font-semibold text-nodered-gray-700">
347-
{questionTitle ? questionTitle : 'Rating Analysis'}
348-
</h3>
347+
<ChartHeader title={questionTitle ? questionTitle : 'Rating Analysis'} />
349348
</div>
350349
{/* Respondent Count Badge */}
351350
{respondentInfo && (

src/components/MatrixChart.jsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
22
import RespondentIcon from './RespondentIcon';
33
import { getTooltipPosition, useHideTooltipOnScroll } from '../utils/tooltip-utils';
44
import Tooltip from './Tooltip';
5+
import ChartHeader from './ChartHeader';
56

67
const MatrixChart = ({ questionId, questionTitle, filters, _color, wasmService }) => {
78
const [data, setData] = useState([]);
@@ -55,7 +56,7 @@ const MatrixChart = ({ questionId, questionTitle, filters, _color, wasmService }
5556
</div>
5657
<div className="flex-1 flex flex-col overflow-hidden">
5758
<div className="px-4 py-3 border-b border-gray-200 bg-white">
58-
<h3 className="text-base font-semibold text-gray-900">{questionTitle}</h3>
59+
<ChartHeader title={questionTitle} />
5960
</div>
6061
<div className="p-6">
6162
<div className="text-gray-500">Loading...</div>
@@ -75,7 +76,7 @@ const MatrixChart = ({ questionId, questionTitle, filters, _color, wasmService }
7576
</div>
7677
<div className="flex-1 flex flex-col overflow-hidden">
7778
<div className="px-4 py-3 border-b border-gray-200 bg-white">
78-
<h3 className="text-base font-semibold text-gray-900">{questionTitle}</h3>
79+
<ChartHeader title={questionTitle} />
7980
</div>
8081
<div className="p-6">
8182
<div className="text-red-500">Error loading data: {error}</div>
@@ -110,9 +111,7 @@ const MatrixChart = ({ questionId, questionTitle, filters, _color, wasmService }
110111
<div className="flex-1">
111112
<div className="flex items-center justify-between">
112113
<div>
113-
<h3 className="text-lg font-semibold text-nodered-gray-700">
114-
{questionTitle}
115-
</h3>
114+
<ChartHeader title={questionTitle} />
116115
</div>
117116
{/* Respondent Count Badge */}
118117
{data.length > 0 && data[0].total_respondents && (

src/components/QualitativeAnalysis.jsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, memo } from 'react';
22
import RespondentIcon from './RespondentIcon';
33
import { getTooltipPosition, useHideTooltipOnScroll } from '../utils/tooltip-utils';
44
import Tooltip from './Tooltip';
5+
import ChartHeader from './ChartHeader';
56

67
const QualitativeAnalysis = ({ questionId, questionText, filters = {}, color = '#64748b', wasmService, baselineOrder }) => {
78
const [data, setData] = useState(null);
@@ -237,9 +238,7 @@ const QualitativeAnalysis = ({ questionId, questionText, filters = {}, color = '
237238
<div className="flex-1">
238239
<div className="flex items-center justify-between">
239240
<div>
240-
<h3 className="text-lg font-semibold text-nodered-gray-700">
241-
{questionText || 'Qualitative Analysis'}
242-
</h3>
241+
<ChartHeader title={questionText || 'Qualitative Analysis'} />
243242
</div>
244243
{/* Respondent Count Badge - Minimal */}
245244
{filteredData && filteredData.respondentCount && (

src/components/QualityComparisonRatingsGrid.jsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getRatingScheme } from '../utils/colorPalette';
33
import RespondentIcon from './RespondentIcon';
44
import { getTooltipPosition, useHideTooltipOnScroll } from '../utils/tooltip-utils';
55
import Tooltip from './Tooltip';
6+
import ChartHeader from './ChartHeader';
67

78
const QUALITY_COMPARISON_QUESTIONS = [
89
{ id: 'RoNjbJ', name: 'Ease of use/Ease to learn' },
@@ -161,9 +162,7 @@ const QualityComparisonRatingsGrid = ({ filters = {}, wasmService }) => {
161162
<div className="flex-1">
162163
{/* Header */}
163164
<div className="px-4 py-3 border-b border-gray-200 bg-white">
164-
<h3 className="text-lg font-semibold text-nodered-gray-700">
165-
Compared to alternatives, Node-RED's quality is ...
166-
</h3>
165+
<ChartHeader title="Compared to alternatives, Node-RED's quality is ..." />
167166
</div>
168167

169168
{/* Questions Grid */}

src/components/QuantitativeChart.jsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { FILTER_MAPPINGS } from '../utils/filter-utils';
44
import { getChartColor, defaultChartColor } from '../utils/colorPalette';
55
import { applyBaselineOrder } from '../utils/ordinalOrdering';
66
import RespondentIcon from './RespondentIcon';
7+
import ChartHeader from './ChartHeader';
78

89
// Map filter questions to their display titles
910
const FILTER_QUESTION_TITLES = {
@@ -170,9 +171,7 @@ const QuantitativeChart = ({ questionId, questionTitle, filterType, filters = {}
170171
<div className="flex-1">
171172
<div className="flex items-center justify-between">
172173
<div>
173-
<h3 className="text-lg font-semibold text-nodered-gray-700">
174-
{actualQuestionTitle}
175-
</h3>
174+
<ChartHeader title={actualQuestionTitle} />
176175
</div>
177176
{/* Respondent Count Badge - Minimal */}
178177
{respondentInfo && (

0 commit comments

Comments
 (0)