diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index f020080e..08003f0e 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -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: { diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 3d1a9c83..39a40aa8 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -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': { diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx index 4ffe0d83..6aa0d4f3 100644 --- a/src/app/components/password-input/PasswordInput.tsx +++ b/src/app/components/password-input/PasswordInput.tsx @@ -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, 'type' | 'size'> & { size: '400' | '500'; @@ -23,6 +24,7 @@ export const PasswordInput = forwardRef( <> {after} setVisible(!visible)} type="button" variant={visible ? 'Warning' : variant} diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx index f1695716..a09501e7 100644 --- a/src/app/features/desk/Desk.tsx +++ b/src/app/features/desk/Desk.tsx @@ -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() { {phone && ( - navigate(getHomePath())}> + navigate(getHomePath())} + > )} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 03926da9..41f67d03 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > setReplyDraft(undefined)} variant="SurfaceVariant" size="300" @@ -638,6 +640,7 @@ export const RoomInput = forwardRef( } before={ pickFile('*')} variant="SurfaceVariant" size="300" @@ -649,6 +652,7 @@ export const RoomInput = forwardRef( after={ <> ( > {!hideStickerBtn && ( setEmojiBoardTab(EmojiBoardTab.Sticker)} variant="SurfaceVariant" @@ -704,6 +709,7 @@ export const RoomInput = forwardRef( )} ( )} - + diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 8089b5ef..3132c77a 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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) => ( {(onBack) => ( - + @@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } > {(triggerRef) => ( - + )} @@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( {(triggerRef) => ( - + )} @@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( void; @@ -111,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( {recentEmojis.map((emoji) => ( Delete Message - + @@ -589,7 +595,12 @@ export const MessageReportItem = as< Report Message - + @@ -950,6 +961,7 @@ export const Message = as<'div', MessageProps>( } > ( )} ( {!isThreadedMessage && ( onReplyClick(ev, true)} data-event-id={mEvent.getId()} variant="SurfaceVariant" @@ -982,6 +996,7 @@ export const Message = as<'div', MessageProps>( )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} variant="SurfaceVariant" size="300" @@ -1147,6 +1162,7 @@ export const Message = as<'div', MessageProps>( } > ( } > = { terrain: 'Terrain', ridge: 'Ridges' }; @@ -149,6 +150,7 @@ export function SkyStateControl() { } > (); @@ -94,6 +95,7 @@ function UsernameHint({ server }: { server: string }) { } > { 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 outside any {} expression. + const iconButtonTags = (src: string) => { + const tags: string[] = []; + for (let i = src.indexOf('' && 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 = { + '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); + } + ); +}); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts new file mode 100644 index 00000000..14ff375c --- /dev/null +++ b/src/app/styles/PhoneHit.css.ts @@ -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 } } }, +]);