Phone: 44 px hit areas for icon buttons, reaction chips and short sender names #125

Merged
void merged 6 commits from void/phone-hit-areas into dev 2026-11-13 04:11:27 +00:00
11 changed files with 207 additions and 10 deletions

View file

@ -1,6 +1,9 @@
import { createVar, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
import { PHONE_TAP } from '../../styles/PhoneHit.css';
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
const Container = createVar();
const ContainerHover = createVar();
const ContainerActive = createVar();
@ -25,6 +28,14 @@ export const Reaction = style([
selectors: {
'button&': {
cursor: 'pointer',
// Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the
// same 9 px of margin opens the row, so the chips of one row and of the next never share a tap.
'@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } },
},
'button&::after': {
'@media': {
[PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` },
},
},
'&[aria-pressed=true]': {
vars: {

View file

@ -299,7 +299,10 @@ export const BubbleLeftArrow = style({
});
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
// No background, border or outline moves.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({
overflow: 'hidden',
@ -309,7 +312,7 @@ export const Username = style({
'button&': {
cursor: 'pointer',
'@media': {
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
},
},
'button&:hover, button&:focus-visible': {

View file

@ -1,6 +1,7 @@
import React, { ComponentProps, forwardRef } from 'react';
import { Icon, IconButton, Input, config, Icons } from 'folds';
import { UseStateProvider } from '../UseStateProvider';
import { PhoneHit } from '../../styles/PhoneHit.css';
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
size: '400' | '500';
@ -23,6 +24,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
<>
{after}
<IconButton
className={PhoneHit}
onClick={() => setVisible(!visible)}
type="button"
variant={visible ? 'Warning' : variant}

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { getHomePath } from '../../pages/pathUtils';
@ -33,7 +34,11 @@ export function Desk() {
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" basis="No">
{phone && (
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
<IconButton
className={PhoneHit}
aria-label="Back to rooms"
onClick={() => navigate(getHomePath())}
>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}

View file

@ -116,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { PhoneHitSpread32 } from '../../styles/PhoneHit.css';
interface RoomInputProps {
editor: Editor;
@ -605,6 +606,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
className={PhoneHitSpread32}
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
@ -638,6 +640,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
before={
<IconButton
className={PhoneHitSpread32}
onClick={() => pickFile('*')}
variant="SurfaceVariant"
size="300"
@ -649,6 +652,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
after={
<>
<IconButton
className={PhoneHitSpread32}
variant="SurfaceVariant"
size="300"
radii="300"
@ -691,6 +695,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
{!hideStickerBtn && (
<IconButton
className={PhoneHitSpread32}
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant"
@ -704,6 +709,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</IconButton>
)}
<IconButton
className={PhoneHitSpread32}
ref={emojiBtnRef}
aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
@ -723,7 +729,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<IconButton
className={PhoneHitSpread32}
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Send} />
</IconButton>
</>

View file

@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
type RoomMenuProps = {
room: Room;
@ -336,6 +337,7 @@ function CallButton() {
>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread40}
variant="Surface"
fill="None"
ref={triggerRef}
@ -455,7 +457,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack}>
<IconButton className={PhoneHitSpread40} fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
<IconButton
className={PhoneHitSpread40}
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread40}
fill="None"
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
@ -612,7 +620,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
<IconButton
className={PhoneHitSpread40}
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread40}
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}

View file

@ -86,6 +86,7 @@ import colorMXID from '../../../../util/colorMXID';
import { useSkyInk } from '../../../hooks/useTheme';
import { userColorInk } from '../../scene/skyInk';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@ -111,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{recentEmojis.map((emoji) => (
<IconButton
key={emoji.unicode}
className={css.MessageQuickReaction}
className={classNames(css.MessageQuickReaction, PhoneHitSpread32)}
size="300"
variant="SurfaceVariant"
radii="Pill"
@ -459,7 +460,12 @@ export const MessageDeleteItem = as<
<Box grow="Yes">
<Text size="H4">Delete Message</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300">
<IconButton
className={PhoneHitSpread32}
size="300"
onClick={handleClose}
radii="300"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -589,7 +595,12 @@ export const MessageReportItem = as<
<Box grow="Yes">
<Text size="H4">Report Message</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300">
<IconButton
className={PhoneHitSpread32}
size="300"
onClick={handleClose}
radii="300"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -950,6 +961,7 @@ export const Message = as<'div', MessageProps>(
}
>
<IconButton
className={PhoneHitRoom26}
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
@ -961,6 +973,7 @@ export const Message = as<'div', MessageProps>(
</PopOut>
)}
<IconButton
className={PhoneHitRoom26}
onClick={onReplyClick}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
@ -971,6 +984,7 @@ export const Message = as<'div', MessageProps>(
</IconButton>
{!isThreadedMessage && (
<IconButton
className={PhoneHitRoom26}
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
@ -982,6 +996,7 @@ export const Message = as<'div', MessageProps>(
)}
{canEditEvent(mx, mEvent) && onEditId && (
<IconButton
className={PhoneHitRoom26}
onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
@ -1147,6 +1162,7 @@ export const Message = as<'div', MessageProps>(
}
>
<IconButton
className={PhoneHitRoom26}
variant="SurfaceVariant"
size="300"
radii="300"
@ -1320,6 +1336,7 @@ export const Event = as<'div', EventProps>(
}
>
<IconButton
className={PhoneHitRoom26}
variant="SurfaceVariant"
size="300"
radii="300"

View file

@ -28,6 +28,7 @@ import {
setSceneStyle,
type ServedSceneStyle,
} from './sceneStyle';
import { PhoneHit } from '../../styles/PhoneHit.css';
const STYLE_LABEL: Record<ServedSceneStyle, string> = { terrain: 'Terrain', ridge: 'Ridges' };
@ -149,6 +150,7 @@ export function SkyStateControl() {
}
>
<IconButton
className={PhoneHit}
variant="Background"
aria-label={`Sky: ${LABEL[state]}`}
title={`Sky: ${LABEL[state]}`}

View file

@ -39,6 +39,7 @@ import { loginErrorMessage } from '../authMessages';
import { ForgotLink } from '../styles.css';
import { getResetPasswordPath } from '../../pathUtils';
import { stopPropagation } from '../../../utils/keyboard';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function UsernameHint({ server }: { server: string }) {
const [anchor, setAnchor] = useState<RectCords>();
@ -94,6 +95,7 @@ function UsernameHint({ server }: { server: string }) {
}
>
<IconButton
className={PhoneHit}
tabIndex={-1}
onClick={handleOpenMenu}
type="button"

View file

@ -1,3 +1,4 @@
import { readFileSync } from 'fs';
import { describe, expect, it } from 'vitest';
import zlib from 'zlib';
import { compile, processVanillaFile } from '@vanilla-extract/integration';
@ -116,3 +117,86 @@ describe('phone navigator rows', () => {
expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2);
});
});
describe('phone icon-button hit areas', () => {
const stripPhone = (css: string) => css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, '');
it('draw a 44 x 44 box around the button on a phone and nothing on any other screen', async () => {
const css = await emitted('styles/PhoneHit.css.ts');
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\);/);
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%\);/);
expect(stripPhone(css).trim()).toBe('');
});
it('open a row up by half the shortfall, so neighbouring hit areas meet and do not overlap', async () => {
const css = await emitted('styles/PhoneHit.css.ts');
const margins = (selector: RegExp) =>
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]));
expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]);
});
it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => {
const css = await emitted('components/message/Reaction.css.ts');
const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE);
expect(chip['margin-block']).toBe((TAP - 26) / 2);
expect(css).toMatch(/::after \{[^}]*inset: -9px 0;/);
rulesFor(css, /^button\.Reaction_Reaction__\w+$/, undefined).forEach((d) =>
expect(Object.keys(d)).not.toContain('margin-block')
);
});
it('give a sender name a 44 px wide button too, text kept at its start', async () => {
const css = await emitted('components/message/layout/layout.css.ts');
const [phone] = rulesFor(css, /^button\.layout_Username__/, PHONE);
expect(phone['min-width']).toBe(TAP);
expect(css).toMatch(/text-align: start;/);
});
});
describe('icon buttons in the room view', () => {
// The opening tag of every <IconButton in a source file, read to its closing > outside any {} expression.
const iconButtonTags = (src: string) => {
const tags: string[] = [];
for (let i = src.indexOf('<IconButton'); i !== -1; i = src.indexOf('<IconButton', i + 1)) {
let depth = 0;
let j = i;
for (; j < src.length; j += 1) {
if (src[j] === '{') depth += 1;
else if (src[j] === '}') depth -= 1;
else if (src[j] === '>' && depth === 0) break;
}
tags.push(src.slice(i, j + 1));
}
return tags;
};
// The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size
// and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area.
const expected: Record<string, string[]> = {
'features/room/message/Message.tsx': [
'PhoneHitSpread32', // quick-reaction emoji buttons
'PhoneHitSpread32', // delete dialog close
'PhoneHitSpread32', // report dialog close
...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu
],
'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'),
'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'),
};
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);
}
);
});

View file

@ -0,0 +1,45 @@
import { style } from '@vanilla-extract/css';
import { MOBILE_BREAKPOINT } from '../hooks/useScreenSize';
// Phone tap target for an icon button: on a phone the button's hit area is 44 x 44 px centred on the drawn
// button (a ::after the button's own click reaches), so nothing drawn changes size. A button with neighbours
// 44 px closer than that would take their taps, so the `Spread` classes also give it horizontal margin of half
// the shortfall: the row grows by that, on phones only, and the hit areas meet instead of overlapping.
// Vertical room is cheaper than horizontal on a phone, so only the horizontal axis pays.
export const PHONE_TAP = 44;
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
const hitArea = {
'@media': {
[PHONE]: {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: `max(100%, ${PHONE_TAP}px)`,
height: `max(100%, ${PHONE_TAP}px)`,
transform: 'translate(-50%, -50%)',
},
},
} as const;
/** An icon button on its own: the hit area grows, nothing else moves. */
export const PhoneHit = style({
'@media': { [PHONE]: { position: 'relative' } },
selectors: { '&::after': hitArea },
});
/** 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 } } }]);
const PhoneHitSpread26 = spread(26);
export const PhoneHitSpread32 = spread(32);
export const PhoneHitSpread40 = spread(40);
/** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row
* opens up on both axes, so the 44 px sits inside the container. */
export const PhoneHitRoom26 = style([
PhoneHitSpread26,
{ '@media': { [PHONE]: { marginBlock: (PHONE_TAP - 26) / 2 } } },
]);