fix(composer): one-row toolbar with uniform 32px buttons on every viewport
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 1m38s
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 1m38s
The composer looked off in two ways, both confirmed by rendering CustomEditor
with RoomInput's exact props and measuring the buttons headlessly:
Desktop: the Lotus additions (location, poll, voice, schedule) used
`Icon size="100"` (18px) inside the same `IconButton size="300"` as the
upstream Aa/sticker/emoji/send buttons (24px icons), so one row mixed
32×32, 26×26 and a 28×19 "GIF" text stub. Every button is now 32×32: the
four small icons use the default icon size and the GIF label sits in a
1.5rem box, the same footprint as an icon. The mic's idle button in
VoiceMessageRecorder gets the same treatment since it lives in this row.
Phones: d6159997 let the before|editable|after row flex-wrap at <=750px, but
folds' Scroll (the editable's wrapper) is `width: 100%`, so the row ALWAYS
broke into three stacked lines — "+" alone on top, the input flush against
the left edge on its own line (the :first-child padding selectors no longer
matched), and emoji/draft/send left-aligned underneath. e1bb8301's "+"
overflow menu was meant to produce [ + | input | emoji | send ] but never
could while the row wrapped. The row no longer wraps (upstream behaviour);
instead the collapse into the "+" overflow is keyed on the viewport
(ScreenSize.Mobile) as well as the touch UA, so a phone-width window on a
desktop UA — iPad desktop mode, split-screen PWA, docked window — also
collapses instead of rendering ten controls inline and clipping Send behind
the editor's overflow:hidden. The "Draft saved" label moves into the overflow
row in compact mode so the inline row stays [ + | input | emoji | count |
send ]. The editable's vertical padding grows to 19px at phone width (only
when the row actually has buttons) so the text sits level with the 44px
touch targets instead of hugging the top of the row. Those touch targets now
also apply the shared MobileTouchTarget class, matching the recorder button.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -248,7 +248,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
radii="300"
|
||||
title="Record voice message"
|
||||
>
|
||||
<Icon src={Icons.Mic} size="100" />
|
||||
<Icon src={Icons.Mic} />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,21 +16,16 @@ export const EditorOptions = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S200,
|
||||
'@media': {
|
||||
// On phones the toolbar can hold many 44px buttons; let them wrap to a
|
||||
// second line instead of overflowing horizontally.
|
||||
'(max-width: 750px)': { flexWrap: 'wrap' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// The composer's before | editable | after row. On phones, allow the toolbar
|
||||
// (`after`) to wrap below the input instead of squeezing the editable to zero
|
||||
// and pushing the Send button off-screen.
|
||||
// The composer's before | editable | after row. It must NOT wrap: folds'
|
||||
// Scroll (the editable's wrapper) is `width: 100%`, so a wrapping row always
|
||||
// breaks into three stacked lines (before / editable / after) — the "wonky"
|
||||
// phone composer. Narrow viewports keep one row by collapsing the secondary
|
||||
// buttons behind the "+" overflow instead (RoomInput `compact`).
|
||||
export const EditorInputRow = style({
|
||||
'@media': {
|
||||
'(max-width: 750px)': { flexWrap: 'wrap' },
|
||||
},
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const EditorTextareaScroll = style({});
|
||||
@@ -41,6 +36,20 @@ export const EditorTextarea = style([
|
||||
flexGrow: 1,
|
||||
height: '100%',
|
||||
padding: `${toRem(13)} ${toRem(1)}`,
|
||||
'@media': {
|
||||
// Phone-width composer rows carry 44px touch targets (MobileTouchTarget),
|
||||
// so the row is 60px instead of 48px; pad the text to keep it level with
|
||||
// the buttons instead of hugging the top of the row. Only when the row
|
||||
// actually has before/after buttons (not the edit-message editor).
|
||||
'(max-width: 750px)': {
|
||||
selectors: {
|
||||
[`${EditorTextareaScroll}:not(:only-child) &`]: {
|
||||
paddingTop: toRem(19),
|
||||
paddingBottom: toRem(19),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
[`${EditorTextareaScroll}:first-child &`]: {
|
||||
paddingLeft: toRem(13),
|
||||
@@ -70,6 +79,13 @@ export const EditorPlaceholderTextVisual = style([
|
||||
display: 'block',
|
||||
paddingTop: toRem(13),
|
||||
paddingLeft: toRem(1),
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
selectors: {
|
||||
[`${EditorTextareaScroll}:not(:only-child) &`]: { paddingTop: toRem(19) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -129,6 +129,8 @@ import { useTheme } from '../../hooks/useTheme';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
||||
import { PollCreator } from './PollCreator';
|
||||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||||
@@ -235,11 +237,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
||||
const isMobile = mobileOrTablet();
|
||||
// On phones the composer's secondary action buttons (attach, GIF, poll,
|
||||
const screenSize = useScreenSizeContext();
|
||||
// Compact composer: the 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).
|
||||
// input stays ONE row — [ + | input | emoji | send ]. Keyed on the viewport,
|
||||
// not only the user agent: a phone-width window on a desktop UA (iPad in
|
||||
// desktop mode, a split-screen PWA, a docked window) otherwise renders all
|
||||
// ten controls inline and clips the Send button behind the editor's
|
||||
// overflow:hidden. Touch-sized (44px) targets stay UA-gated: a narrow
|
||||
// desktop window still has a mouse.
|
||||
const compact = isMobile || screenSize === ScreenSize.Mobile;
|
||||
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||||
// Both gates on purpose: the class covers phone-width viewports (matches
|
||||
// VoiceMessageRecorder's idle button, which lives in the same row), the
|
||||
// inline style covers touch UAs with a wider viewport (tablet landscape).
|
||||
const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||
@@ -1140,7 +1151,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
)
|
||||
}
|
||||
before={
|
||||
isMobile ? (
|
||||
compact ? (
|
||||
<IconButton
|
||||
onClick={() => setMobileToolsOpen((open) => !open)}
|
||||
aria-label="More actions"
|
||||
@@ -1150,6 +1161,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
<Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
|
||||
</IconButton>
|
||||
@@ -1161,6 +1173,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
@@ -1174,6 +1187,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||
aria-pressed={toolbar}
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
@@ -1200,6 +1214,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Sticker}
|
||||
@@ -1220,6 +1235,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Smile}
|
||||
@@ -1307,22 +1323,32 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
radii="300"
|
||||
disabled={gifUploading}
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
{gifUploading ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
) : (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.04em',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
GIF
|
||||
</Text>
|
||||
)}
|
||||
{/* Sized like a default Icon (1.5rem) so the button
|
||||
matches its 32px neighbours instead of a 28×19 stub. */}
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ width: '1.5rem', height: '1.5rem' }}
|
||||
>
|
||||
{gifUploading ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
) : (
|
||||
<Text
|
||||
as="span"
|
||||
size="T200"
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.04em',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
GIF
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
)}
|
||||
@@ -1340,11 +1366,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
radii="300"
|
||||
title="Share location"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
{locating ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
) : (
|
||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||
<Icon src={Icons.SpaceGlobe} />
|
||||
)}
|
||||
</IconButton>
|
||||
) : null;
|
||||
@@ -1359,8 +1386,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
radii="300"
|
||||
title="Create poll"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
<Icon src={Icons.OrderList} size="100" />
|
||||
<Icon src={Icons.OrderList} />
|
||||
</IconButton>
|
||||
) : null;
|
||||
|
||||
@@ -1383,10 +1411,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
aria-label="Schedule message"
|
||||
title="Schedule message"
|
||||
>
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
<Icon src={Icons.Clock} />
|
||||
</IconButton>
|
||||
) : null;
|
||||
|
||||
@@ -1426,16 +1455,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile: keep only emoji/sticker inline beside Send; the rest move
|
||||
// Compact: 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.
|
||||
// button that `before` gives up on, and trailed by the draft label so
|
||||
// the inline row stays [ emoji | count | send ]. Wide viewports render
|
||||
// everything 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) {
|
||||
if (compact) {
|
||||
composerOverflow = (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -1446,17 +1477,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
{overflowButtons}
|
||||
<DraftIndicator roomId={draftKey} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMobile ? emojiInline : orderedButtons}
|
||||
{compact ? emojiInline : orderedButtons}
|
||||
{gifError && (
|
||||
<Text
|
||||
size="T200"
|
||||
@@ -1483,7 +1516,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{locationError}
|
||||
</Text>
|
||||
)}
|
||||
<DraftIndicator roomId={draftKey} />
|
||||
{!compact && <DraftIndicator roomId={draftKey} />}
|
||||
{charCount > 0 && (
|
||||
<Text
|
||||
size="T200"
|
||||
@@ -1505,6 +1538,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Icon src={Icons.Send} />
|
||||
@@ -1514,7 +1548,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
})()}
|
||||
bottom={
|
||||
<>
|
||||
{isMobile && mobileToolsOpen && composerOverflow && (
|
||||
{compact && mobileToolsOpen && composerOverflow && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Box
|
||||
|
||||
Reference in New Issue
Block a user