Skip to content

Commit 1ce5117

Browse files
feat(apollo-react): add i18n support for canvas components [MST-8210]
Add Lingui localization to the sticky note formatting toolbar with a new canvas locale catalog (13 locales). Simplify the i18n build by switching compileNamespace from 'es' to 'ts' so rslib handles ESM/CJS/d.ts output natively — removing plugin-copy-locales and the ambient *.mjs type hack. Remove the Lingui Babel macro from Storybook since canvas components no longer use msg(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 55c5bd1 commit 1ce5117

23 files changed

Lines changed: 443 additions & 418 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ storybook-static/
2323
*.tsbuildinfo
2424

2525
# Compiled locale files (generated by lingui compile)
26-
**/locales/*.js
26+
**/locales/*.ts
2727

2828
# Playwright MCP
2929
.playwright-mcp

packages/apollo-react/lingui.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@ const config: LinguiConfig = {
1313
path: 'src/material/components/ap-tool-call/locales/{locale}',
1414
include: ['src/material/components/ap-tool-call'],
1515
},
16+
{
17+
path: 'src/canvas/locales/{locale}',
18+
include: ['src/canvas'],
19+
},
1620
],
1721
format: formatter({ style: 'minimal' }),
22+
compileNamespace: 'ts',
1823
};
1924

2025
export default config;

packages/apollo-react/rslib.config.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import { pluginReact } from '@rsbuild/plugin-react';
33
import type { RslibConfig } from '@rslib/core';
44
import { defineConfig } from '@rslib/core';
55

6-
import { pluginCopyLocales } from './scripts/plugin-copy-locales';
7-
86
// Shared externals list to avoid duplication
97
const externals = [
108
'react',
@@ -60,7 +58,6 @@ export default defineConfig({
6058
'!./src/test/**',
6159
'!./src/icons/.cache',
6260
'!./src/**/*.md',
63-
'!./src/**/locales/*.js',
6461
'!./src/**/locales/*.json',
6562
'!./src/**/.DS_Store',
6663
],
@@ -79,7 +76,6 @@ export default defineConfig({
7976
return opts;
8077
},
8178
}),
82-
pluginCopyLocales(),
8379
],
8480
output: {
8581
target: 'web',

packages/apollo-react/scripts/plugin-copy-locales.ts

Lines changed: 0 additions & 84 deletions
This file was deleted.

packages/apollo-react/src/canvas/components/StickyNoteNode/FormattingToolbar.test.tsx

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { i18n } from '@lingui/core';
12
import { fireEvent, render, screen } from '@testing-library/react';
23
import { describe, expect, it, vi } from 'vitest';
4+
import { ApI18nProvider, type SupportedLocale } from '../../../i18n';
35
import { FormattingToolbar } from './FormattingToolbar';
46
import type { ActiveFormats } from './markdown-formatting';
57

@@ -21,12 +23,20 @@ function createMockTextArea(value = '', selectionStart = 0, selectionEnd = 0) {
2123
return { current: textarea };
2224
}
2325

26+
function renderWithI18n(ui: React.ReactElement, locale: SupportedLocale = 'en') {
27+
return render(
28+
<ApI18nProvider component="canvas" locale={locale}>
29+
{ui}
30+
</ApI18nProvider>
31+
);
32+
}
33+
2434
describe('FormattingToolbar', () => {
2535
it('calls onFormat when a button is clicked', () => {
2636
const onFormat = vi.fn();
2737
const ref = createMockTextArea('hello world', 6, 11);
2838

29-
render(
39+
renderWithI18n(
3040
<FormattingToolbar
3141
textAreaRef={ref}
3242
borderColor="#42A1FF"
@@ -46,7 +56,7 @@ describe('FormattingToolbar', () => {
4656
const onFormat = vi.fn();
4757
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
4858

49-
render(
59+
renderWithI18n(
5060
<FormattingToolbar
5161
textAreaRef={ref}
5262
borderColor="#42A1FF"
@@ -59,9 +69,75 @@ describe('FormattingToolbar', () => {
5969
expect(onFormat).not.toHaveBeenCalled();
6070
});
6171

72+
it('exposes the container as an accessible toolbar', () => {
73+
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
74+
renderWithI18n(
75+
<FormattingToolbar
76+
textAreaRef={ref}
77+
borderColor="#42A1FF"
78+
activeFormats={defaultFormats}
79+
onFormat={vi.fn()}
80+
/>
81+
);
82+
83+
expect(screen.getByRole('toolbar', { name: /Text formatting/i })).toBeTruthy();
84+
});
85+
86+
it('exposes an accessible name on every toolbar button', () => {
87+
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
88+
renderWithI18n(
89+
<FormattingToolbar
90+
textAreaRef={ref}
91+
borderColor="#42A1FF"
92+
activeFormats={defaultFormats}
93+
onFormat={vi.fn()}
94+
/>
95+
);
96+
97+
// All five icon-only buttons must resolve via accessible name so screen
98+
// readers announce them. Shortcut text comes from ICU interpolation — if
99+
// that breaks, these queries surface the regression.
100+
expect(screen.getByRole('button', { name: /^Bold \(.+\+B\)$/ })).toBeTruthy();
101+
expect(screen.getByRole('button', { name: /^Italic \(.+\+I\)$/ })).toBeTruthy();
102+
expect(screen.getByRole('button', { name: /^Strikethrough \(.+X\)$/ })).toBeTruthy();
103+
expect(screen.getByRole('button', { name: /^Bullet list$/ })).toBeTruthy();
104+
expect(screen.getByRole('button', { name: /^Numbered list$/ })).toBeTruthy();
105+
});
106+
107+
it('renders translated labels when a non-English locale is active', () => {
108+
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
109+
110+
// Load a fake Spanish catalog with translations for the toolbar keys
111+
i18n.load('es', {
112+
'sticky-note.formatting.bold': ['Negrita (', ['boldShortcut'], ')'],
113+
'sticky-note.formatting.italic': ['Cursiva (', ['italicShortcut'], ')'],
114+
'sticky-note.formatting.strikethrough': ['Tachado (', ['strikethroughShortcut'], ')'],
115+
'sticky-note.formatting.bullet-list': ['Lista con viñetas'],
116+
'sticky-note.formatting.numbered-list': ['Lista numerada'],
117+
'sticky-note.formatting.toolbar': ['Formato de texto'],
118+
});
119+
120+
renderWithI18n(
121+
<FormattingToolbar
122+
textAreaRef={ref}
123+
borderColor="#42A1FF"
124+
activeFormats={defaultFormats}
125+
onFormat={vi.fn()}
126+
/>,
127+
'es'
128+
);
129+
130+
expect(screen.getByRole('toolbar', { name: 'Formato de texto' })).toBeTruthy();
131+
expect(screen.getByRole('button', { name: /^Negrita \(.+\+B\)$/ })).toBeTruthy();
132+
expect(screen.getByRole('button', { name: /^Cursiva \(.+\+I\)$/ })).toBeTruthy();
133+
expect(screen.getByRole('button', { name: /^Tachado \(.+X\)$/ })).toBeTruthy();
134+
expect(screen.getByRole('button', { name: /^Lista con viñetas$/ })).toBeTruthy();
135+
expect(screen.getByRole('button', { name: /^Lista numerada$/ })).toBeTruthy();
136+
});
137+
62138
it('prevents textarea blur on mousedown', () => {
63139
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
64-
const { container } = render(
140+
const { container } = renderWithI18n(
65141
<FormattingToolbar
66142
textAreaRef={ref}
67143
borderColor="#42A1FF"
@@ -70,7 +146,7 @@ describe('FormattingToolbar', () => {
70146
/>
71147
);
72148

73-
const toolbarContainer = container.firstChild as HTMLElement;
149+
const toolbarContainer = container.querySelector('[class]') as HTMLElement;
74150
const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
75151
const prevented = !toolbarContainer.dispatchEvent(mouseDownEvent);
76152
expect(prevented).toBe(true);

packages/apollo-react/src/canvas/components/StickyNoteNode/FormattingToolbar.tsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useLingui } from '@lingui/react';
12
import { CanvasIcon } from '@uipath/apollo-react/canvas';
23
import { memo, type RefObject, useCallback } from 'react';
34
import { CanvasTooltip } from '../CanvasTooltip';
@@ -30,6 +31,10 @@ const FormattingToolbarComponent = ({
3031
activeFormats,
3132
onFormat,
3233
}: FormattingToolbarProps) => {
34+
const { _ } = useLingui();
35+
const mod = getModifierKey();
36+
const shift = isMac() ? '⇧' : '+Shift+';
37+
3338
const applyFormat = useCallback(
3439
(formatFn: (input: TextSelection) => TextSelection) => {
3540
const textarea = textAreaRef.current;
@@ -53,40 +58,84 @@ const FormattingToolbarComponent = ({
5358
const handleBulletList = useCallback(() => applyFormat(toggleBulletList), [applyFormat]);
5459
const handleNumberedList = useCallback(() => applyFormat(toggleNumberedList), [applyFormat]);
5560

56-
const mod = getModifierKey();
57-
const shift = isMac() ? '⇧' : '+Shift+';
61+
// Hoisted so each label is used for both the tooltip text AND the button's
62+
// aria-label — icon-only buttons otherwise have no accessible name.
63+
// Values go inside the descriptor (Lingui v5 `_(descriptor)` overload does not
64+
// take a separate values arg).
65+
const boldLabel = _({
66+
id: 'sticky-note.formatting.bold',
67+
message: 'Bold ({boldShortcut})',
68+
values: { boldShortcut: `${mod}+B` },
69+
});
70+
const italicLabel = _({
71+
id: 'sticky-note.formatting.italic',
72+
message: 'Italic ({italicShortcut})',
73+
values: { italicShortcut: `${mod}+I` },
74+
});
75+
const strikethroughLabel = _({
76+
id: 'sticky-note.formatting.strikethrough',
77+
message: 'Strikethrough ({strikethroughShortcut})',
78+
values: { strikethroughShortcut: `${mod}${shift}X` },
79+
});
80+
const bulletListLabel = _({
81+
id: 'sticky-note.formatting.bullet-list',
82+
message: 'Bullet list',
83+
});
84+
const numberedListLabel = _({
85+
id: 'sticky-note.formatting.numbered-list',
86+
message: 'Numbered list',
87+
});
88+
const toolbarLabel = _({ id: 'sticky-note.formatting.toolbar', message: 'Text formatting' });
5889

5990
return (
6091
<FormattingToolbarContainer
92+
role="toolbar"
93+
aria-label={toolbarLabel}
6194
borderColor={borderColor}
6295
onMouseDown={(e) => e.preventDefault()}
6396
className="nodrag nowheel"
6497
>
65-
<CanvasTooltip content={`Bold (${mod}+B)`} placement="top" delay>
66-
<FormattingButton isActive={activeFormats.bold} onClick={handleBold}>
98+
<CanvasTooltip content={boldLabel} placement="top" delay>
99+
<FormattingButton isActive={activeFormats.bold} onClick={handleBold} aria-label={boldLabel}>
67100
<CanvasIcon icon="bold" size={14} />
68101
</FormattingButton>
69102
</CanvasTooltip>
70-
<CanvasTooltip content={`Italic (${mod}+I)`} placement="top" delay>
71-
<FormattingButton isActive={activeFormats.italic} onClick={handleItalic}>
103+
<CanvasTooltip content={italicLabel} placement="top" delay>
104+
<FormattingButton
105+
isActive={activeFormats.italic}
106+
onClick={handleItalic}
107+
aria-label={italicLabel}
108+
>
72109
<CanvasIcon icon="italic" size={14} />
73110
</FormattingButton>
74111
</CanvasTooltip>
75-
<CanvasTooltip content={`Strikethrough (${mod}${shift}X)`} placement="top" delay>
76-
<FormattingButton isActive={activeFormats.strikethrough} onClick={handleStrikethrough}>
112+
<CanvasTooltip content={strikethroughLabel} placement="top" delay>
113+
<FormattingButton
114+
isActive={activeFormats.strikethrough}
115+
onClick={handleStrikethrough}
116+
aria-label={strikethroughLabel}
117+
>
77118
<CanvasIcon icon="strikethrough" size={14} />
78119
</FormattingButton>
79120
</CanvasTooltip>
80121

81122
<ToolbarSeparator />
82123

83-
<CanvasTooltip content="Bullet list" placement="top" delay>
84-
<FormattingButton isActive={activeFormats.bulletList} onClick={handleBulletList}>
124+
<CanvasTooltip content={bulletListLabel} placement="top" delay>
125+
<FormattingButton
126+
isActive={activeFormats.bulletList}
127+
onClick={handleBulletList}
128+
aria-label={bulletListLabel}
129+
>
85130
<CanvasIcon icon="list" size={14} />
86131
</FormattingButton>
87132
</CanvasTooltip>
88-
<CanvasTooltip content="Numbered list" placement="top" delay>
89-
<FormattingButton isActive={activeFormats.numberedList} onClick={handleNumberedList}>
133+
<CanvasTooltip content={numberedListLabel} placement="top" delay>
134+
<FormattingButton
135+
isActive={activeFormats.numberedList}
136+
onClick={handleNumberedList}
137+
aria-label={numberedListLabel}
138+
>
90139
<CanvasIcon icon="list-ordered" size={14} />
91140
</FormattingButton>
92141
</CanvasTooltip>

packages/apollo-react/src/canvas/components/StickyNoteNode/StickyNoteNode.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { NodeResizeControl, useReactFlow } from '@uipath/apollo-react/canvas/xyf
55
import type { ResizeDragEvent, ResizeParams } from '@uipath/apollo-react/canvas/xyflow/system';
66
import { AnimatePresence } from 'motion/react';
77
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
8+
import { ApI18nProvider } from '../../../i18n';
89
import ReactMarkdown from 'react-markdown';
910
import remarkBreaks from 'remark-breaks';
1011
import remarkGfm from 'remark-gfm';
@@ -348,7 +349,7 @@ const StickyNoteNodeComponent = ({
348349
<TopCornerIndicators selected={selected} />
349350
<BottomCornerIndicators selected={selected} />
350351
{isEditing ? (
351-
<>
352+
<ApI18nProvider component="canvas">
352353
<FormattingToolbar
353354
textAreaRef={textAreaRef}
354355
borderColor={color}
@@ -367,7 +368,7 @@ const StickyNoteNodeComponent = ({
367368
isEditing={isEditing}
368369
className="nodrag nowheel"
369370
/>
370-
</>
371+
</ApI18nProvider>
371372
) : (
372373
<StickyNoteMarkdown ref={markdownRef} {...scrollCaptureProps}>
373374
{localContent ? (

0 commit comments

Comments
 (0)