Phone: 44 px hit areas for the chat path's remaining icon buttons, and tests that pin them #127

Merged
void merged 3 commits from void/phone-hit-areas-2 into dev 2026-11-13 05:50:21 +00:00
10 changed files with 90 additions and 10 deletions

View file

@ -66,6 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
type RoomNavItemMenuProps = {
room: Room;
@ -229,6 +230,7 @@ function CallChatToggle() {
return (
<IconButton
className={PhoneHitSpread24}
onClick={() => setChat(!chat)}
aria-pressed={chat}
aria-label="Toggle Chat"
@ -436,6 +438,7 @@ export function RoomNavItem({
}
>
<IconButton
className={PhoneHitSpread24}
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
aria-controls={`menu-${room.roomId}`}

View file

@ -6,6 +6,7 @@ import { Page, PageHeader } from '../../components/page';
import { callChatAtom } from '../../state/callEmbed';
import { RoomView } from './RoomView';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function CallChatView() {
const { eventId } = useParams();
@ -41,7 +42,12 @@ export function CallChatView() {
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose}>
<IconButton
className={PhoneHit}
ref={triggerRef}
variant="Surface"
onClick={handleClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}

View file

@ -59,6 +59,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { PhoneHit } from '../../styles/PhoneHit.css';
type MemberDrawerHeaderProps = {
room: Room;
@ -87,6 +88,7 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
>
{(triggerRef) => (
<IconButton
className={PhoneHit}
ref={triggerRef}
variant="Background"
onClick={() => setPeopleDrawer(false)}
@ -359,6 +361,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
<IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)}
variant="Surface"
radii="Pill"

View file

@ -10,6 +10,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './RoomViewTyping.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type RoomViewTypingProps = {
room: Room;
@ -111,7 +112,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
</>
)}
</Text>
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
<IconButton
className={PhoneHit}
title="Drop Typing Status"
size="300"
radii="Pill"
onClick={handleDropAll}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../
import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type JumpToTimeProps = {
onCancel: () => void;
@ -108,7 +109,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
<Box grow="Yes">
<Text size="H4">Jump to Time</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -54,6 +54,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck';
import { PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
type MessageEditorProps = {
roomId: string;
@ -289,6 +290,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
</Box>
<Box gap="Inherit">
<IconButton
className={PhoneHitSpread32}
variant="SurfaceVariant"
size="300"
radii="300"
@ -322,6 +324,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
}
>
<IconButton
className={PhoneHitSpread32}
aria-pressed={anchor !== undefined}
onClick={
((evt) =>

View file

@ -28,6 +28,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { getMouseEventCords } from '../../../utils/dom';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export type ReactionViewerProps = {
room: Room;
@ -100,7 +101,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
<Box grow="Yes">
<Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
</Box>
<IconButton size="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -86,6 +86,7 @@ import {
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { PhoneHit, PhoneHitSpread26 } from '../../../styles/PhoneHit.css';
type PinnedMessageProps = {
room: Room;
@ -146,6 +147,7 @@ function PinnedMessage({
</Chip>
{canPinEvent && (
<IconButton
className={PhoneHitSpread26}
data-event-id={eventId}
variant="Secondary"
size="300"
@ -457,7 +459,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
<Text size="H5">Pinned Messages</Text>
</Box>
<Box shrink="No">
<IconButton size="300" onClick={requestClose} radii="300">
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} size="400" />
</IconButton>
</Box>

View file

@ -126,11 +126,35 @@ describe('phone icon-button hit areas', () => {
const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)];
expect(phone.map((m) => m[1])).toEqual([PHONE]);
expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/);
// no content, no box: the pseudo-element is not rendered at all
expect(css).toMatch(/::after \{[^}]*content: "";/);
expect(css).toMatch(/::after \{[^}]*position: absolute;/);
// centred on the button: top and left at half, pulled back by half its own size
expect(css).toMatch(/::after \{[^}]*top: 50%;/);
expect(css).toMatch(/::after \{[^}]*left: 50%;/);
expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/);
// the box anchors to its own button, receives the tap, and sits above nothing it should not
expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/);
const afterDecls = [...css.matchAll(/::after \{\n([^}]*)\}/g)].flatMap((m) =>
m[1]
.split('\n')
.map((l) => l.trim().split(':')[0])
.filter(Boolean)
);
expect([...afterDecls].sort()).toEqual([
'content',
'height',
'left',
'position',
'top',
'transform',
'width',
]);
// nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the box)
expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([
'absolute',
'relative',
]);
expect(stripPhone(css).trim()).toBe('');
});
@ -140,7 +164,9 @@ describe('phone icon-button hit areas', () => {
rulesFor(css, selector, PHONE).map((d) => d['margin-inline']);
// 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically,
// the menu around them clips a hit area drawn outside its box)
expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([9, 6, 2]));
// each class owns its own margin: (44 - drawn size) / 2
const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`));
expect([own(24), own(26), own(32), own(40)]).toEqual([[10], [9], [6], [2]]);
expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]);
});
@ -190,13 +216,37 @@ describe('icon buttons in the room view', () => {
],
'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'),
'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'),
// The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit.
'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji
'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread24', 'PhoneHitSpread24'], // chat toggle, options: 16 px icon + 8 padding = 24 px, 0 gap
'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top
'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'],
'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHitSpread26', 'PhoneHit'], // unpin (26 px, 8 px from the Open chip), close
'features/room/RoomViewTyping.tsx': ['PhoneHit'],
'features/room/CallChatView.tsx': ['PhoneHit'],
'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'],
// Buttons that were fixed with #125 and had no test.
'components/password-input/PasswordInput.tsx': ['PhoneHit'],
'features/desk/Desk.tsx': ['PhoneHit'],
'features/scene/SkyStateControl.tsx': ['PhoneHit'],
'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'],
};
it.each(Object.entries(expected))(
'%s: each IconButton carries its own PhoneHit class',
(file, want) => {
const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'));
expect(tags.map((t) => t.match(/\bPhoneHit\w+/)?.[0] ?? null)).toEqual(want);
const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8');
expect(
iconButtonTags(src).map((t) => t.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null)
).toEqual(want);
// the name is the one PhoneHit.css.ts exports, not a local of the same name
[...new Set(want)].forEach((name) =>
expect(src).toMatch(
new RegExp(
`import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'`
)
)
);
}
);
});

View file

@ -31,9 +31,13 @@ export const PhoneHit = style({
/** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */
const spread = (visual: number) =>
style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]);
style(
[PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }],
`PhoneHitSpread${visual}`
);
const PhoneHitSpread26 = spread(26);
export const PhoneHitSpread24 = spread(24);
export const PhoneHitSpread26 = spread(26);
export const PhoneHitSpread32 = spread(32);
export const PhoneHitSpread40 = spread(40);