Files
ds-chat/frontend/src/components/FormattingToolbar.test.tsx
T
ksmithandClaude Sonnet 5.5 6975ed4dc0 Add a collapsible markdown formatting toolbar to the composer (#75)
Markdown support was already complete and documented, but nothing in the
composer pointed at it -- unlike @, # and : (which advertise themselves
when typed), formatting syntax was only discoverable via Help, buried in
the account menu.

The composer stays a plain markdown textarea; a new "Aa" button opens a
toolbar that inserts the syntax for you: bold, italic, strikethrough,
inline code, code block, link, quote, bulleted/numbered lists, H1-H3,
sub/superscript. Collapsed by default, remembered across reloads and
room switches. Clicking a button again removes the formatting (native
undo doesn't track programmatic edits), and bold/italic/sub/strike
toggles are run-length aware so "***x***" is handled correctly. The
selection math is pure functions in lib/markdownFormat.ts, unit-tested.

The "?" opens /help#formatting in a new tab so the draft isn't lost;
HelpPage now scrolls to the hash once the guide has loaded, since the
browser's own hash scroll fired before the async-fetched headings
existed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:06:56 -06:00

56 lines
2.4 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { FormatAction } from '../lib/markdownFormat'
import { FormattingToolbar } from './FormattingToolbar'
afterEach(cleanup)
const EXPECTED: [string, FormatAction][] = [
['Bold', { kind: 'inline', marker: '**' }],
['Italic', { kind: 'inline', marker: '*' }],
['Strikethrough', { kind: 'inline', marker: '~~' }],
['Inline code', { kind: 'inline', marker: '`' }],
['Code block', { kind: 'codeBlock' }],
['Link', { kind: 'link' }],
['Quote', { kind: 'prefix', prefix: 'quote' }],
['Bulleted list', { kind: 'prefix', prefix: 'bullet' }],
['Numbered list', { kind: 'prefix', prefix: 'ordered' }],
['Heading 1', { kind: 'heading', level: 1 }],
['Heading 2', { kind: 'heading', level: 2 }],
['Heading 3', { kind: 'heading', level: 3 }],
['Subscript', { kind: 'inline', marker: '~' }],
['Superscript', { kind: 'inline', marker: '^' }],
]
describe('FormattingToolbar', () => {
it.each(EXPECTED)('%s button calls onAction with its action', (label, action) => {
const onAction = vi.fn()
render(<FormattingToolbar id="t" onAction={onAction} />)
fireEvent.click(screen.getByRole('button', { name: label }))
expect(onAction).toHaveBeenCalledTimes(1)
expect(onAction).toHaveBeenCalledWith(action)
})
it('disables every button when disabled, but leaves the help link usable', () => {
render(<FormattingToolbar id="t" disabled onAction={vi.fn()} />)
const buttons = screen.getAllByRole('button') as HTMLButtonElement[]
expect(buttons).toHaveLength(EXPECTED.length)
expect(buttons.every((b) => b.disabled)).toBe(true)
expect(screen.getByRole('link', { name: 'Formatting help' })).toBeTruthy()
})
it('opens help in a new tab so the draft in the composer is not lost', () => {
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
const help = screen.getByRole('link', { name: 'Formatting help' })
expect(help.getAttribute('href')).toBe('/help#formatting')
expect(help.getAttribute('target')).toBe('_blank')
expect(help.getAttribute('rel')).toContain('noopener')
})
it('keeps focus in the textarea by cancelling mousedown on buttons', () => {
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
// fireEvent returns false when the event's default was prevented.
expect(fireEvent.mouseDown(screen.getByRole('button', { name: 'Bold' }))).toBe(false)
})
})