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:
@@ -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"
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
Reference in New Issue
Block a user