fix(composer): collapse mobile action buttons behind a "+" overflow menu

On phones the composer's 7-8 secondary action buttons wrapped into a tall
multi-row stack ("massive height"). Mobile now shows a single compact row —
[ + | input | emoji | send ] — where "+" toggles a collapsible row (above the
formatting toolbar) holding attach, GIF, location, poll, voice, formatting and
schedule. Desktop is unchanged (isMobile === false; the mobile branches are
never entered and composerOverflow stays null).

The after-builder stashes the collapsed buttons in a render-local `let` that
the bottom slot reads; safe because JSX props evaluate in source order within
one render (verified by review). Emoji/Send stay inline; the emoji and GIF
PopOut anchors still resolve wherever their button renders.

Review fixes folded in: the "+" toggle uses aria-expanded + aria-controls
(dropped the redundant aria-pressed) pointing at the labelled role="group"
overflow row; the voice recorder's idle mic button gets the @media-gated
MobileTouchTarget 44px target so the overflow row is uniformly tappable.

Two review agents (correctness + UX/a11y); gate-green (tsc, eslint, prettier,
914 tests, build). Visual confirmation still wants a real device per
LOTUS_TESTING.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-24 14:44:13 -04:00
parent 098e3c900f
commit e1bb8301f0
2 changed files with 93 additions and 18 deletions
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { MobileTouchTarget } from '../styles/mobile.css';
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
@@ -239,6 +240,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
if (state === 'idle') {
return (
<IconButton
className={MobileTouchTarget}
onClick={startRecording}
aria-label="Record voice message"
variant="SurfaceVariant"
+91 -18
View File
@@ -230,7 +230,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
const touchTarget = mobileOrTablet() ? { minWidth: '44px', minHeight: '44px' } : undefined;
const isMobile = mobileOrTablet();
// On phones the composer's secondary action buttons (attach, GIF, poll,
// location, voice, formatting, schedule) collapse behind a "+" toggle so the
// input stays one compact row instead of wrapping into a tall stack. Emoji +
// Send remain inline. Desktop keeps everything inline (isMobile === false).
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
const showFormat = composerToolbarButtons?.showFormat ?? true;
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
const showSticker = composerToolbarButtons?.showSticker ?? true;
@@ -876,6 +882,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
}
// Mobile "+" overflow: the `after` builder stashes the collapsed secondary
// buttons here and the `bottom` slot renders them when the toggle is open.
// React evaluates JSX props in source order (before → after → bottom), so
// `after` assigns this before `bottom` reads it within the same render.
let composerOverflow: ReactNode = null;
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
@@ -1035,16 +1047,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
)
}
before={
<IconButton
onClick={() => pickFile('*')}
aria-label="Attach file"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={Icons.PlusCircle} />
</IconButton>
isMobile ? (
<IconButton
onClick={() => setMobileToolsOpen((open) => !open)}
aria-label="More actions"
aria-expanded={mobileToolsOpen}
aria-controls="composer-more-actions"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
</IconButton>
) : (
<IconButton
onClick={() => pickFile('*')}
aria-label="Attach file"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={Icons.PlusCircle} />
</IconButton>
)
}
after={(() => {
const formatButton = showFormat ? (
@@ -1306,9 +1333,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
});
// Mobile: keep only emoji/sticker inline beside Send; the rest move
// into the "+" overflow row (rendered via `bottom`), led by the attach
// button that `before` gives up on mobile. Desktop renders all inline.
const emojiInline = orderedButtons.filter(
(node) => React.isValidElement(node) && node.key === 'showEmojiSticker',
);
const overflowButtons = orderedButtons.filter(
(node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'),
);
if (isMobile) {
composerOverflow = (
<>
<IconButton
key="showAttach"
onClick={() => pickFile('*')}
aria-label="Attach file"
variant="SurfaceVariant"
size="300"
radii="300"
style={touchTarget}
>
<Icon src={Icons.PlusCircle} />
</IconButton>
{overflowButtons}
</>
);
}
return (
<>
{orderedButtons}
{isMobile ? emojiInline : orderedButtons}
{gifError && (
<Text
size="T200"
@@ -1365,12 +1420,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
})()}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
<>
{isMobile && mobileToolsOpen && composerOverflow && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Box
id="composer-more-actions"
role="group"
aria-label="More actions"
alignItems="Center"
gap="100"
wrap="Wrap"
style={{ padding: config.space.S200 }}
>
{composerOverflow}
</Box>
</div>
)}
{toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)}
</>
}
/>
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}