Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ storybook-static/
*.tsbuildinfo

# Compiled locale files (generated by lingui compile)
**/locales/*.js
**/locales/*.ts

# Playwright MCP
.playwright-mcp
Expand Down
5 changes: 5 additions & 0 deletions packages/apollo-react/lingui.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ const config: LinguiConfig = {
path: 'src/material/components/ap-tool-call/locales/{locale}',
include: ['src/material/components/ap-tool-call'],
},
{
path: 'src/canvas/locales/{locale}',
include: ['src/canvas'],
},
],
format: formatter({ style: 'minimal' }),
compileNamespace: 'ts',
};

export default config;
4 changes: 0 additions & 4 deletions packages/apollo-react/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import { pluginReact } from '@rsbuild/plugin-react';
import type { RslibConfig } from '@rslib/core';
import { defineConfig } from '@rslib/core';

import { pluginCopyLocales } from './scripts/plugin-copy-locales';

// Shared externals list to avoid duplication
const externals = [
'react',
Expand Down Expand Up @@ -60,7 +58,6 @@ export default defineConfig({
'!./src/test/**',
'!./src/icons/.cache',
'!./src/**/*.md',
'!./src/**/locales/*.js',
'!./src/**/locales/*.json',
'!./src/**/.DS_Store',
],
Expand All @@ -79,7 +76,6 @@ export default defineConfig({
return opts;
},
}),
pluginCopyLocales(),
],
output: {
target: 'web',
Expand Down
84 changes: 0 additions & 84 deletions packages/apollo-react/scripts/plugin-copy-locales.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { i18n } from '@lingui/core';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ApI18nProvider, type SupportedLocale } from '../../../i18n';
import { FormattingToolbar } from './FormattingToolbar';
import type { ActiveFormats } from './markdown-formatting';

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

function renderWithI18n(ui: React.ReactElement, locale: SupportedLocale = 'en') {
return render(
<ApI18nProvider component="canvas" locale={locale}>
{ui}
</ApI18nProvider>
);
}

describe('FormattingToolbar', () => {
it('calls onFormat when a button is clicked', () => {
const onFormat = vi.fn();
const ref = createMockTextArea('hello world', 6, 11);

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

render(
renderWithI18n(
<FormattingToolbar
textAreaRef={ref}
borderColor="#42A1FF"
Expand All @@ -59,9 +69,75 @@ describe('FormattingToolbar', () => {
expect(onFormat).not.toHaveBeenCalled();
});

it('exposes the container as an accessible toolbar', () => {
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
renderWithI18n(
<FormattingToolbar
textAreaRef={ref}
borderColor="#42A1FF"
activeFormats={defaultFormats}
onFormat={vi.fn()}
/>
);

expect(screen.getByRole('toolbar', { name: /Text formatting/i })).toBeTruthy();
});

it('exposes an accessible name on every toolbar button', () => {
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
renderWithI18n(
<FormattingToolbar
textAreaRef={ref}
borderColor="#42A1FF"
activeFormats={defaultFormats}
onFormat={vi.fn()}
/>
);

// All five icon-only buttons must resolve via accessible name so screen
// readers announce them. Shortcut text comes from ICU interpolation — if
// that breaks, these queries surface the regression.
expect(screen.getByRole('button', { name: /^Bold \(.+\+B\)$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Italic \(.+\+I\)$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Strikethrough \(.+X\)$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Bullet list$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Numbered list$/ })).toBeTruthy();
});

it('renders translated labels when a non-English locale is active', () => {
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;

// Load a fake Spanish catalog with translations for the toolbar keys
i18n.load('es', {
'sticky-note.formatting.bold': ['Negrita (', ['boldShortcut'], ')'],
'sticky-note.formatting.italic': ['Cursiva (', ['italicShortcut'], ')'],
'sticky-note.formatting.strikethrough': ['Tachado (', ['strikethroughShortcut'], ')'],
'sticky-note.formatting.bullet-list': ['Lista con viñetas'],
'sticky-note.formatting.numbered-list': ['Lista numerada'],
'sticky-note.formatting.toolbar': ['Formato de texto'],
});

renderWithI18n(
<FormattingToolbar
textAreaRef={ref}
borderColor="#42A1FF"
activeFormats={defaultFormats}
onFormat={vi.fn()}
/>,
'es'
);

expect(screen.getByRole('toolbar', { name: 'Formato de texto' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Negrita \(.+\+B\)$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Cursiva \(.+\+I\)$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Tachado \(.+X\)$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Lista con viñetas$/ })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Lista numerada$/ })).toBeTruthy();
});

it('prevents textarea blur on mousedown', () => {
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
const { container } = render(
const { container } = renderWithI18n(
<FormattingToolbar
textAreaRef={ref}
borderColor="#42A1FF"
Expand All @@ -70,7 +146,7 @@ describe('FormattingToolbar', () => {
/>
);

const toolbarContainer = container.firstChild as HTMLElement;
const toolbarContainer = container.querySelector('[class]') as HTMLElement;
const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
const prevented = !toolbarContainer.dispatchEvent(mouseDownEvent);
expect(prevented).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useLingui } from '@lingui/react';
import { CanvasIcon } from '@uipath/apollo-react/canvas';
import { memo, type RefObject, useCallback } from 'react';
import { CanvasTooltip } from '../CanvasTooltip';
Expand Down Expand Up @@ -30,6 +31,10 @@ const FormattingToolbarComponent = ({
activeFormats,
onFormat,
}: FormattingToolbarProps) => {
const { _ } = useLingui();
const mod = getModifierKey();
const shift = isMac() ? '⇧' : '+Shift+';

const applyFormat = useCallback(
(formatFn: (input: TextSelection) => TextSelection) => {
const textarea = textAreaRef.current;
Expand All @@ -53,40 +58,84 @@ const FormattingToolbarComponent = ({
const handleBulletList = useCallback(() => applyFormat(toggleBulletList), [applyFormat]);
const handleNumberedList = useCallback(() => applyFormat(toggleNumberedList), [applyFormat]);

const mod = getModifierKey();
const shift = isMac() ? '⇧' : '+Shift+';
// Hoisted so each label is used for both the tooltip text AND the button's
// aria-label — icon-only buttons otherwise have no accessible name.
// Values go inside the descriptor (Lingui v5 `_(descriptor)` overload does not
// take a separate values arg).
const boldLabel = _({
id: 'sticky-note.formatting.bold',
message: 'Bold ({boldShortcut})',
values: { boldShortcut: `${mod}+B` },
});
const italicLabel = _({
id: 'sticky-note.formatting.italic',
message: 'Italic ({italicShortcut})',
values: { italicShortcut: `${mod}+I` },
});
const strikethroughLabel = _({
id: 'sticky-note.formatting.strikethrough',
message: 'Strikethrough ({strikethroughShortcut})',
values: { strikethroughShortcut: `${mod}${shift}X` },
});
const bulletListLabel = _({
id: 'sticky-note.formatting.bullet-list',
message: 'Bullet list',
});
const numberedListLabel = _({
id: 'sticky-note.formatting.numbered-list',
message: 'Numbered list',
});
const toolbarLabel = _({ id: 'sticky-note.formatting.toolbar', message: 'Text formatting' });

return (
<FormattingToolbarContainer
role="toolbar"
aria-label={toolbarLabel}
borderColor={borderColor}
onMouseDown={(e) => e.preventDefault()}
className="nodrag nowheel"
>
<CanvasTooltip content={`Bold (${mod}+B)`} placement="top" delay>
<FormattingButton isActive={activeFormats.bold} onClick={handleBold}>
<CanvasTooltip content={boldLabel} placement="top" delay>
<FormattingButton isActive={activeFormats.bold} onClick={handleBold} aria-label={boldLabel}>
<CanvasIcon icon="bold" size={14} />
</FormattingButton>
</CanvasTooltip>
<CanvasTooltip content={`Italic (${mod}+I)`} placement="top" delay>
<FormattingButton isActive={activeFormats.italic} onClick={handleItalic}>
<CanvasTooltip content={italicLabel} placement="top" delay>
<FormattingButton
isActive={activeFormats.italic}
onClick={handleItalic}
aria-label={italicLabel}
>
<CanvasIcon icon="italic" size={14} />
</FormattingButton>
</CanvasTooltip>
<CanvasTooltip content={`Strikethrough (${mod}${shift}X)`} placement="top" delay>
<FormattingButton isActive={activeFormats.strikethrough} onClick={handleStrikethrough}>
<CanvasTooltip content={strikethroughLabel} placement="top" delay>
<FormattingButton
isActive={activeFormats.strikethrough}
onClick={handleStrikethrough}
aria-label={strikethroughLabel}
>
<CanvasIcon icon="strikethrough" size={14} />
</FormattingButton>
</CanvasTooltip>

<ToolbarSeparator />

<CanvasTooltip content="Bullet list" placement="top" delay>
<FormattingButton isActive={activeFormats.bulletList} onClick={handleBulletList}>
<CanvasTooltip content={bulletListLabel} placement="top" delay>
<FormattingButton
isActive={activeFormats.bulletList}
onClick={handleBulletList}
aria-label={bulletListLabel}
>
<CanvasIcon icon="list" size={14} />
</FormattingButton>
</CanvasTooltip>
<CanvasTooltip content="Numbered list" placement="top" delay>
<FormattingButton isActive={activeFormats.numberedList} onClick={handleNumberedList}>
<CanvasTooltip content={numberedListLabel} placement="top" delay>
<FormattingButton
isActive={activeFormats.numberedList}
onClick={handleNumberedList}
aria-label={numberedListLabel}
>
<CanvasIcon icon="list-ordered" size={14} />
</FormattingButton>
</CanvasTooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { NodeResizeControl, useReactFlow } from '@uipath/apollo-react/canvas/xyf
import type { ResizeDragEvent, ResizeParams } from '@uipath/apollo-react/canvas/xyflow/system';
import { AnimatePresence } from 'motion/react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ApI18nProvider } from '../../../i18n';
Comment thread
david-rios-uipath marked this conversation as resolved.
import ReactMarkdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
Expand Down Expand Up @@ -348,7 +349,7 @@ const StickyNoteNodeComponent = ({
<TopCornerIndicators selected={selected} />
<BottomCornerIndicators selected={selected} />
{isEditing ? (
<>
<ApI18nProvider component="canvas">
<FormattingToolbar
textAreaRef={textAreaRef}
borderColor={color}
Expand All @@ -367,7 +368,7 @@ const StickyNoteNodeComponent = ({
isEditing={isEditing}
className="nodrag nowheel"
/>
</>
</ApI18nProvider>
) : (
<StickyNoteMarkdown ref={markdownRef} {...scrollCaptureProps}>
{localContent ? (
Expand Down
Loading
Loading