diff --git a/web/src/components/AssistantChat/ComposerButtons.test.tsx b/web/src/components/AssistantChat/ComposerButtons.test.tsx index 183b970f..b2663df4 100644 --- a/web/src/components/AssistantChat/ComposerButtons.test.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.test.tsx @@ -1,8 +1,32 @@ -import type { ReactElement } from 'react' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + AssistantRuntimeProvider, + type ChatModelAdapter, + useLocalRuntime, +} from '@assistant-ui/react' +import type { ReactElement, ReactNode } from 'react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' -import { DictationButton, UnifiedButton } from './ComposerButtons' +import { + ComposerButtons, + ComposerExpandButton, + DictationButton, + getComposerToolbarJustifyContent, + UnifiedButton, +} from './ComposerButtons' + +const adapter: ChatModelAdapter = { + async *run() {}, +} + +function RuntimeProviders(props: { children: ReactNode }) { + const runtime = useLocalRuntime(adapter) + return ( + + {props.children} + + ) +} function renderInProviders(ui: ReactElement) { return render({ui}) @@ -103,3 +127,83 @@ describe('DictationButton', () => { expect(onVoiceToggle).toHaveBeenCalledOnce() }) }) + +describe('ComposerExpandButton', () => { + afterEach(() => { + cleanup() + }) + + it('announces and triggers expansion', () => { + const onToggle = vi.fn() + renderInProviders() + + const button = getButton('Expand message editor') + expect(button.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(button) + expect(onToggle).toHaveBeenCalledOnce() + }) + + it('announces the collapse action while expanded', () => { + renderInProviders( {}} />) + + const button = getButton('Collapse message editor') + expect(button.getAttribute('aria-pressed')).toBe('true') + expect(button.className).toContain('text-[var(--app-link)]') + }) +}) + +describe('ComposerButtons responsive toolbar', () => { + afterEach(cleanup) + + it('keeps toolbar actions non-shrinking inside a horizontal scroll area', () => { + const noop = () => {} + render( + +
+ +
+
, + ) + + const toolbar = screen.getByTestId('composer-toolbar-items') + expect(toolbar.className).toContain('overflow-x-auto') + const toolbarButtons = within(toolbar).getAllByRole('button') + expect(toolbarButtons.length).toBeGreaterThanOrEqual(8) + for (const button of toolbarButtons) { + expect(button.closest('.shrink-0')).not.toBeNull() + } + }) + + it('uses overflow-safe alignment for centered and right-aligned layouts', () => { + expect(getComposerToolbarJustifyContent('center')).toBe('safe center') + expect(getComposerToolbarJustifyContent('right')).toBe('safe end') + expect(getComposerToolbarJustifyContent('left')).toBe('flex-start') + expect(getComposerToolbarJustifyContent('split')).toBe('flex-start') + }) +}) diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 2701502f..61fa9213 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -18,7 +18,11 @@ function OrderedToolbarItems(props: { layout: ComposerToolbarLayout; children: R (child): child is ReactElement<{ item: ComposerToolbarItemId; children: ReactNode }> => isValidElement(child), ) const slotsByItem = new Map(slots.map((slot) => [slot.props.item, slot])) - const renderItems = (items: ComposerToolbarItemId[]) => items.map((item) => slotsByItem.get(item) ?? null) + const renderItems = (items: ComposerToolbarItemId[]) => items.map((item) => { + const slot = slotsByItem.get(item) + if (!slot || slot.props.children == null) return null + return
{slot}
+ }) if (props.layout.mode === 'split') { return <>{renderItems(props.layout.left)}