Skip to content

Commit 82496f7

Browse files
feat(Message): Add support for footnotes (#648)
Styled footnotes and added demos with some onClick events. Co-authored-by: Erin Donehoo <[email protected]> Assisted-by: Cursor (used for debugging demos, copying bot work to user message demo, and removing node from all components except table)
1 parent 399d013 commit 82496f7

File tree

12 files changed

+512
-42
lines changed

12 files changed

+512
-42
lines changed

packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { CSSProperties, useState, Fragment, FunctionComponent, MouseEvent, Ref } from 'react';
1+
import {
2+
CSSProperties,
3+
useState,
4+
Fragment,
5+
FunctionComponent,
6+
MouseEvent as ReactMouseEvent,
7+
KeyboardEvent as ReactKeyboardEvent,
8+
Ref
9+
} from 'react';
210
import Message from '@patternfly/chatbot/dist/dynamic/Message';
311
import patternflyAvatar from './patternfly_avatar.jpg';
412
import squareImg from './PF-social-color-square.svg';
@@ -44,6 +52,8 @@ export const BotMessageExample: FunctionComponent = () => {
4452
return table;
4553
case 'Image':
4654
return image;
55+
case 'Footnote':
56+
return footnote;
4757
default:
4858
return;
4959
}
@@ -150,6 +160,18 @@ _Italic text, formatted with single underscores_
150160

151161
const image = `![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`;
152162

163+
const footnote = `This is some text that has a short footnote[^1] and this is text with a longer footnote.[^bignote]
164+
165+
[^1]: This is a short footnote. To return the highlight to the original message, click the arrow.
166+
167+
[^bignote]: This is a long footnote with multiple paragraphs and formatting.
168+
169+
To break long footnotes into paragraphs, indent the text.
170+
171+
Add as many paragraphs as you like. You can include *italic text*, **bold text**, and \`code\`.
172+
173+
> You can even include blockquotes in footnotes!`;
174+
153175
const error = {
154176
title: 'Could not load chat',
155177
children: 'Wait a few minutes and check your network settings. If the issue persists: ',
@@ -165,8 +187,8 @@ _Italic text, formatted with single underscores_
165187
)
166188
};
167189

168-
const onSelect = (_event: MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
169-
setVariant(value);
190+
const onSelect = (_event: ReactMouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
191+
setVariant(value as string);
170192
setSelected(value as string);
171193
setIsOpen(false);
172194
if (value === 'Expandable code') {
@@ -196,6 +218,76 @@ _Italic text, formatted with single underscores_
196218
</MenuToggle>
197219
);
198220

221+
const handleFootnoteNavigation = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
222+
const target = event.target as HTMLElement;
223+
224+
// Depending on whether it is a click event or keyboard event, target may be a link or something like a span
225+
// Look for the closest anchor element (could be a parent)
226+
const anchorElement = target.closest('a');
227+
const href = anchorElement?.getAttribute('href');
228+
229+
// Check if this is a footnote link - we only have internal links in this example, so this is all we need here
230+
if (href && href.startsWith('#')) {
231+
// Prevent default behavior to avoid page re-render on click in PatternFly docs framework
232+
event.preventDefault();
233+
234+
let targetElement: HTMLElement | null = null;
235+
const targetId = href.replace('#', '');
236+
targetElement = document.querySelector(`[id="${targetId}"]`);
237+
238+
if (targetElement) {
239+
let focusTarget = targetElement;
240+
241+
// If we found a footnote definition container, focus on the parent li element
242+
if (targetElement.id?.startsWith('bot-message-fn-')) {
243+
// Find the parent li element that contains the footnote
244+
const parentLi = targetElement.closest('li');
245+
if (parentLi) {
246+
focusTarget = parentLi as HTMLElement;
247+
}
248+
}
249+
250+
focusTarget.focus();
251+
252+
let elementToHighlight = targetElement;
253+
254+
// If this is a backref link (going back to footnote reference),
255+
// we want to highlight more of the ref line and not just the link itself
256+
// since the target is so small
257+
if (targetElement.id?.startsWith('bot-message-fnref-')) {
258+
const refLink = targetElement;
259+
260+
// Walk up the DOM to find a meaningful container
261+
let parent = refLink.parentElement;
262+
while (parent && parent.tagName.toLowerCase() !== 'p' && parent !== document.body) {
263+
parent = parent.parentElement;
264+
}
265+
266+
// Use if found, otherwise use the immediate parent or target as a fallback
267+
elementToHighlight = parent || refLink.parentElement || targetElement;
268+
}
269+
270+
// Briefly highlight the target element for fun to show what you can do
271+
const originalBackground = elementToHighlight.style.backgroundColor;
272+
const originalTransition = elementToHighlight.style.transition;
273+
274+
elementToHighlight.style.transition = 'background-color 0.3s ease';
275+
elementToHighlight.style.backgroundColor = 'var(--pf-t--global--background--color--tertiary--default)';
276+
277+
setTimeout(() => {
278+
elementToHighlight.style.backgroundColor = originalBackground;
279+
setTimeout(() => {
280+
elementToHighlight.style.transition = originalTransition;
281+
}, 300);
282+
}, 1000);
283+
}
284+
}
285+
};
286+
287+
const onClick = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
288+
handleFootnoteNavigation(event);
289+
};
290+
199291
return (
200292
<>
201293
<Message
@@ -248,6 +340,7 @@ _Italic text, formatted with single underscores_
248340
<SelectOption value="More complex list">More complex list</SelectOption>
249341
<SelectOption value="Table">Table</SelectOption>
250342
<SelectOption value="Image">Image</SelectOption>
343+
<SelectOption value="Footnote">Footnote</SelectOption>
251344
<SelectOption value="Error">Error</SelectOption>
252345
</SelectList>
253346
</Select>
@@ -265,6 +358,11 @@ _Italic text, formatted with single underscores_
265358
// The purpose of this plugin is to provide unique link names for the code blocks
266359
// Because they are in the same message, this requires a custom plugin to parse the syntax tree
267360
additionalRehypePlugins={[rehypeCodeBlockToggle]}
361+
linkProps={{ onClick }}
362+
// clobberPrefix controls the label ids
363+
reactMarkdownProps={{
364+
remarkRehypeOptions: { footnoteLabel: 'Bot message footnotes', clobberPrefix: 'bot-message-' }
365+
}}
268366
/>
269367
</>
270368
);

packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { Fragment, useState, useRef, useEffect, CSSProperties, FunctionComponent, MouseEvent, Ref } from 'react';
1+
import {
2+
Fragment,
3+
useState,
4+
useRef,
5+
useEffect,
6+
CSSProperties,
7+
FunctionComponent,
8+
MouseEvent as ReactMouseEvent,
9+
KeyboardEvent as ReactKeyboardEvent,
10+
Ref
11+
} from 'react';
212
import Message from '@patternfly/chatbot/dist/dynamic/Message';
313
import userAvatar from './user_avatar.svg';
414
import {
@@ -64,6 +74,8 @@ export const UserMessageExample: FunctionComponent = () => {
6474
return table;
6575
case 'Image':
6676
return image;
77+
case 'Footnote':
78+
return footnote;
6779
default:
6880
return '';
6981
}
@@ -170,6 +182,18 @@ _Italic text, formatted with single underscores_
170182

171183
const image = `![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`;
172184

185+
const footnote = `This is some text that has a short footnote[^1] and this is text with a longer footnote.[^bignote]
186+
187+
[^1]: This is a short footnote. To return the highlight to the original message, click the arrow.
188+
189+
[^bignote]: This is a long footnote with multiple paragraphs and formatting.
190+
191+
To break long footnotes into paragraphs, indent the text.
192+
193+
Add as many paragraphs as you like. You can include *italic text*, **bold text**, and \`code\`.
194+
195+
> You can even include blockquotes in footnotes!`;
196+
173197
const error = {
174198
title: 'Could not load chat',
175199
children: 'Wait a few minutes and check your network settings. If the issue persists: ',
@@ -185,7 +209,7 @@ _Italic text, formatted with single underscores_
185209
)
186210
};
187211

188-
const onSelect = (_event: MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
212+
const onSelect = (_event: ReactMouseEvent<Element> | undefined, value: string | number | undefined) => {
189213
setVariant(value);
190214
setSelected(value as string);
191215
setIsOpen(false);
@@ -221,6 +245,78 @@ _Italic text, formatted with single underscores_
221245
</MenuToggle>
222246
);
223247

248+
const handleFootnoteNavigation = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
249+
const target = event.target as HTMLElement;
250+
251+
// Depending on whether it is a click event or keyboard event, target may be a link or something like a span
252+
// Look for the closest anchor element (could be a parent)
253+
const anchorElement = target.closest('a');
254+
const href = anchorElement?.getAttribute('href');
255+
256+
// Check if this is a footnote link - we only have internal links in this example, so this is all we need here
257+
if (href && href.startsWith('#')) {
258+
// Prevent default behavior to avoid page re-render on click in PatternFly docs framework
259+
event.preventDefault();
260+
261+
let targetElement: HTMLElement | null = null;
262+
const targetId = href.replace('#', '');
263+
targetElement = document.querySelector(`[id="${targetId}"]`);
264+
265+
if (targetElement) {
266+
let focusTarget = targetElement;
267+
268+
// If we found a footnote definition container, focus on the parent li element
269+
if (targetElement.id?.startsWith('user-message-fn-')) {
270+
// Find the parent li element that contains the footnote
271+
const parentLi = targetElement.closest('li');
272+
if (parentLi) {
273+
focusTarget = parentLi as HTMLElement;
274+
}
275+
}
276+
277+
focusTarget.focus();
278+
279+
let elementToHighlight = targetElement;
280+
const searchStartElement = targetElement;
281+
let elementToHighlightContainer: HTMLElement | null = null;
282+
283+
// For footnote references, look for an appropriate container
284+
if (!targetElement.id?.startsWith('user-message-fn-')) {
285+
let parent = searchStartElement.parentElement;
286+
while (
287+
parent &&
288+
!(parent.tagName.toLowerCase() === 'span' && parent.classList.contains('pf-chatbot__message-text')) &&
289+
parent !== document.body
290+
) {
291+
parent = parent.parentElement;
292+
}
293+
elementToHighlightContainer = parent;
294+
}
295+
296+
// Use the found container if available, otherwise fall back to the target element
297+
elementToHighlight = elementToHighlightContainer || targetElement;
298+
299+
// Briefly highlight the target element for fun to show what you can do
300+
const originalBackground = elementToHighlight.style.backgroundColor;
301+
const originalTransition = elementToHighlight.style.transition;
302+
303+
elementToHighlight.style.transition = 'background-color 0.3s ease';
304+
elementToHighlight.style.backgroundColor = 'var(--pf-t--global--icon--color--brand--hover)';
305+
306+
setTimeout(() => {
307+
elementToHighlight.style.backgroundColor = originalBackground;
308+
setTimeout(() => {
309+
elementToHighlight.style.transition = originalTransition;
310+
}, 300);
311+
}, 1000);
312+
}
313+
}
314+
};
315+
316+
const onClick = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
317+
handleFootnoteNavigation(event);
318+
};
319+
224320
return (
225321
<>
226322
<Message
@@ -270,6 +366,7 @@ _Italic text, formatted with single underscores_
270366
<SelectOption value="More complex list">More complex list</SelectOption>
271367
<SelectOption value="Table">Table</SelectOption>
272368
<SelectOption value="Image">Image</SelectOption>
369+
<SelectOption value="Footnote">Footnote</SelectOption>
273370
<SelectOption value="Error">Error</SelectOption>
274371
</SelectList>
275372
</Select>
@@ -287,6 +384,14 @@ _Italic text, formatted with single underscores_
287384
// The purpose of this plugin is to provide unique link names for the code blocks
288385
// Because they are in the same message, this requires a custom plugin to parse the syntax tree
289386
additionalRehypePlugins={[rehypeCodeBlockToggle]}
387+
linkProps={{ onClick }}
388+
// clobberPrefix controls the label ids
389+
reactMarkdownProps={{
390+
remarkRehypeOptions: {
391+
footnoteLabel: 'User message footnotes',
392+
clobberPrefix: 'user-message-'
393+
}
394+
}}
290395
/>
291396
</>
292397
);

packages/module/src/Message/CodeBlockMessage/CodeBlockMessage.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,9 @@
7777
}
7878

7979
.pf-chatbot__message-inline-code {
80+
--pf-chatbot-message-text-inline-code-font-size: var(--pf-t--global--font--size--body--default);
8081
background-color: var(--pf-t--global--background--color--tertiary--default);
81-
font-size: var(--pf-t--global--font--size--body--default);
82+
font-size: var(--pf-chatbot-message-text-inline-code-font-size);
8283
}
8384

8485
.pf-chatbot__message-code-toggle {

packages/module/src/Message/LinkMessage/LinkMessage.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
import { Button, ButtonProps } from '@patternfly/react-core';
66
import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons';
7+
import { ExtraProps } from 'react-markdown';
78

8-
const LinkMessage = ({ children, target, href, ...props }: ButtonProps) => {
9+
const LinkMessage = ({ children, target, href, id, ...props }: ButtonProps & ExtraProps) => {
910
if (target === '_blank') {
1011
return (
1112
<Button
@@ -16,6 +17,8 @@ const LinkMessage = ({ children, target, href, ...props }: ButtonProps) => {
1617
iconPosition="end"
1718
isInline
1819
target={target}
20+
// need to explicitly call this out or id doesn't seem to get passed - required for footnotes
21+
id={id}
1922
{...props}
2023
>
2124
{children}
@@ -24,7 +27,8 @@ const LinkMessage = ({ children, target, href, ...props }: ButtonProps) => {
2427
}
2528

2629
return (
27-
<Button isInline component="a" href={href} variant="link" {...props}>
30+
// need to explicitly call this out or id doesn't seem to get passed - required for footnotes
31+
<Button isInline component="a" href={href} variant="link" id={id} {...props}>
2832
{children}
2933
</Button>
3034
);

packages/module/src/Message/ListMessage/ListItemMessage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
import { ExtraProps } from 'react-markdown';
66
import { ListItem } from '@patternfly/react-core';
77

8-
const ListItemMessage = ({ children }: JSX.IntrinsicElements['li'] & ExtraProps) => <ListItem>{children}</ListItem>;
8+
const ListItemMessage = ({ children, ...props }: JSX.IntrinsicElements['li'] & ExtraProps) => (
9+
<ListItem {...props} tabIndex={props?.id?.includes('fn-') ? -1 : props?.tabIndex}>
10+
{children}
11+
</ListItem>
12+
);
913

1014
export default ListItemMessage;

packages/module/src/Message/ListMessage/ListMessage.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,22 @@
2121
background-color: var(--pf-t--global--color--brand--default);
2222
color: var(--pf-t--global--text--color--on-brand--default);
2323
padding: var(--pf-t--global--spacer--sm);
24+
25+
// prevents issues when highlighting things like footnotes - don't have blue on blue
26+
.pf-chatbot__message-text {
27+
background-color: initial;
28+
}
29+
}
30+
31+
// targets footnotes specifically and prevents misalignment problems
32+
.footnotes {
33+
li > span {
34+
display: inline-flex;
35+
flex-direction: column;
36+
}
37+
}
38+
39+
li a {
40+
color: var(--pf-t--global--text--color--on-brand--default);
2441
}
2542
}

0 commit comments

Comments
 (0)