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 { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { MobileTouchTarget } from '../styles/mobile.css';
|
||||||
|
|
||||||
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
||||||
|
|
||||||
@@ -239,6 +240,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
if (state === 'idle') {
|
if (state === 'idle') {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
className={MobileTouchTarget}
|
||||||
onClick={startRecording}
|
onClick={startRecording}
|
||||||
aria-label="Record voice message"
|
aria-label="Record voice message"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -230,7 +230,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
|
|
||||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
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 showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||||
const showSticker = composerToolbarButtons?.showSticker ?? 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 (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
@@ -1035,16 +1047,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
<IconButton
|
isMobile ? (
|
||||||
onClick={() => pickFile('*')}
|
<IconButton
|
||||||
aria-label="Attach file"
|
onClick={() => setMobileToolsOpen((open) => !open)}
|
||||||
variant="SurfaceVariant"
|
aria-label="More actions"
|
||||||
size="300"
|
aria-expanded={mobileToolsOpen}
|
||||||
radii="300"
|
aria-controls="composer-more-actions"
|
||||||
style={touchTarget}
|
variant="SurfaceVariant"
|
||||||
>
|
size="300"
|
||||||
<Icon src={Icons.PlusCircle} />
|
radii="300"
|
||||||
</IconButton>
|
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={(() => {
|
after={(() => {
|
||||||
const formatButton = showFormat ? (
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{orderedButtons}
|
{isMobile ? emojiInline : orderedButtons}
|
||||||
{gifError && (
|
{gifError && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1365,12 +1420,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
bottom={
|
bottom={
|
||||||
toolbar && (
|
<>
|
||||||
<div>
|
{isMobile && mobileToolsOpen && composerOverflow && (
|
||||||
<Line variant="SurfaceVariant" size="300" />
|
<div>
|
||||||
<Toolbar />
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
</div>
|
<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)} />}
|
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user