Phone hit: editor toolbar, emoji sidebar, upload cards, client root #139
6 changed files with 124 additions and 0 deletions
|
|
@ -36,6 +36,7 @@ import { KeySymbol } from '../../utils/key-symbol';
|
|||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
|
||||
|
||||
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
|
||||
return (
|
||||
|
|
@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread36}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -104,6 +106,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread36}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -163,6 +166,7 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(1)}
|
||||
size="400"
|
||||
|
|
@ -178,6 +182,7 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(2)}
|
||||
size="400"
|
||||
|
|
@ -193,6 +198,7 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(3)}
|
||||
size="400"
|
||||
|
|
@ -208,6 +214,7 @@ export function HeadingBlockButton() {
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
style={{ width: 'unset' }}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleMenuOpen}
|
||||
|
|
@ -239,6 +246,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -344,6 +352,7 @@ export function Toolbar() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={() => setIsMarkdown(!isMarkdown)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
export function Sidebar({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
|
@ -68,6 +69,7 @@ function SidebarBtn<T extends string>({
|
|||
>
|
||||
{(ref) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
aria-pressed={active}
|
||||
aria-labelledby={`SidebarStackItem-${id}-label`}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||
import { TUploadContent } from '../../utils/matrix';
|
||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type CompactUploadCardRendererProps = {
|
||||
isEncrypted?: boolean;
|
||||
|
|
@ -63,6 +64,7 @@ export function CompactUploadCardRenderer({
|
|||
</Chip>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitSpread28}
|
||||
onClick={removeUpload}
|
||||
aria-label="Cancel Upload"
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../state/room/roomInputDrafts';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type PreviewImageProps = {
|
||||
fileItem: TUploadItem;
|
||||
|
|
@ -160,6 +161,7 @@ export function UploadCardRenderer({
|
|||
</Chip>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitSpread28}
|
||||
onClick={removeUpload}
|
||||
aria-label="Cancel Upload"
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { SyncStatus } from './SyncStatus';
|
|||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||
import { getFallbackSession } from '../../state/sessions';
|
||||
import { AutoDiscovery } from './AutoDiscovery';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
function ClientRootLoading() {
|
||||
return (
|
||||
|
|
@ -61,6 +62,7 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
|||
|
||||
return (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: config.space.S100,
|
||||
|
|
|
|||
107
src/app/phoneHit.editor.test.ts
Normal file
107
src/app/phoneHit.editor.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { MOBILE_BREAKPOINT } from './hooks/useScreenSize';
|
||||
import {
|
||||
classLists,
|
||||
emitted,
|
||||
expectPhoneHitClasses,
|
||||
expectSizeTie,
|
||||
iconButtonTags,
|
||||
readSource,
|
||||
rulesFor,
|
||||
type SizeAllow,
|
||||
} from './phoneHitTestKit';
|
||||
|
||||
describe('icon buttons in the editor, emoji board, upload cards and client root', () => {
|
||||
// The class each IconButton must carry, in source order.
|
||||
const expected: Record<string, string[]> = {
|
||||
'components/editor/Toolbar.tsx': [
|
||||
'PhoneHitSpread36', // mark button: size 400 + 20 px icon, in a row 4 px apart
|
||||
'PhoneHitSpread36', // block button, same row
|
||||
'PhoneHitRoom36', // heading 1, inside the heading Menu (overflow hidden)
|
||||
'PhoneHitRoom36', // heading 2
|
||||
'PhoneHitRoom36', // heading 3
|
||||
'PhoneHit', // heading opener: two 20 px icons, 56 px wide, only its height is short
|
||||
'PhoneHit', // Exit chip: text label, wider than 44 px
|
||||
'PhoneHit', // markdown toggle, alone at the end of the toolbar
|
||||
],
|
||||
// A column of 40 px buttons 4 px apart (the stack's gap="100", pinned below): there is no vertical variant, so only
|
||||
// PhoneHit; the 44 px areas then touch with no overlap.
|
||||
'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'],
|
||||
// Both cards put a Retry chip beside the cancel button when the upload failed.
|
||||
'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'],
|
||||
'components/upload-card/CompactUploadCardRenderer.tsx': ['PhoneHitSpread28'],
|
||||
'pages/client/ClientRoot.tsx': ['PhoneHit'], // alone, absolute in the corner
|
||||
};
|
||||
|
||||
const sizeAllow: SizeAllow = {};
|
||||
|
||||
it.each(Object.entries(expected))(
|
||||
'%s: each IconButton carries its own PhoneHit class',
|
||||
(file, want) => expectPhoneHitClasses(file, want)
|
||||
);
|
||||
|
||||
it.each(Object.keys(expected))('%s: Spread and Room variants name the size they draw', (file) =>
|
||||
expectSizeTie(file, sizeAllow)
|
||||
);
|
||||
});
|
||||
|
||||
/** The JSX element opened at `open` (index of its `<Box`), to its matching `</Box>`: nesting is counted, not guessed (no self-closing Box here). */
|
||||
const boxSpan = (src: string, open: number) => {
|
||||
const marks = [...src.slice(open).matchAll(/<Box(?=[\s>])|<\/Box>/g)];
|
||||
const depths = marks.map((m, i) =>
|
||||
marks.slice(0, i + 1).reduce((d, x) => d + (x[0] === '</Box>' ? -1 : 1), 0)
|
||||
);
|
||||
const end = depths.indexOf(0);
|
||||
if (end === -1) throw new Error('unbalanced <Box>');
|
||||
return [open, open + (marks[end].index ?? 0) + marks[end][0].length] as const;
|
||||
};
|
||||
|
||||
describe('the estate navigator find button', () => {
|
||||
// The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because
|
||||
// the Targets class sets a min size, and the button sits inside the Box that carries that class.
|
||||
it('is 44 px by the Targets class instead', async () => {
|
||||
const src = readSource('features/estate/EstateNavigator.tsx');
|
||||
const tags = iconButtonTags(src);
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]).not.toMatch(/PhoneHit/);
|
||||
|
||||
const header = src.indexOf('<PageNavHeader>');
|
||||
const boxStart = src.indexOf('<Box', header);
|
||||
const [from, to] = boxSpan(src, boxStart);
|
||||
expect(src.slice(boxStart, src.indexOf('>', boxStart))).toMatch(/className=\{css\.Targets\}/);
|
||||
const button = src.indexOf('<IconButton');
|
||||
expect(button).toBeGreaterThan(from);
|
||||
expect(button).toBeLessThan(to);
|
||||
|
||||
// The rule is on the Targets class's own ident, for buttons, at the phone breakpoint.
|
||||
const ident = (await classLists('features/estate/EstateNavigator.css.ts')).Targets[0];
|
||||
const css = await emitted('features/estate/EstateNavigator.css.ts');
|
||||
const phone = rulesFor(
|
||||
css,
|
||||
new RegExp(`^\\.${ident} button(,|$)`),
|
||||
`screen and (max-width: ${MOBILE_BREAKPOINT}px)`
|
||||
);
|
||||
expect(phone).toEqual([{ 'min-height': 44, 'min-width': 44 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the PhoneHit class relies on', () => {
|
||||
// PhoneHit makes the button position: relative on a phone; the inline style wins over a class, so the corner
|
||||
// button stays absolute and its ::after anchors to it.
|
||||
it('ClientRoot keeps its corner button absolutely positioned inline', () => {
|
||||
const [tag] = iconButtonTags(readSource('pages/client/ClientRoot.tsx'));
|
||||
expect(tag).toMatch(/style=\{\{[^}]*position:\s*'absolute'/);
|
||||
});
|
||||
|
||||
// A 40 px button with a 44 px area, in a column 4 px apart: the areas touch and do not overlap. A smaller gap
|
||||
// overlaps them, a larger one leaves a dead strip.
|
||||
it('the emoji sidebar stack keeps its 4 px gap', () => {
|
||||
const src = readSource('components/emoji-board/components/Sidebar.tsx');
|
||||
const stack = src.slice(
|
||||
src.indexOf('export const SidebarStack'),
|
||||
src.indexOf('export function SidebarDivider')
|
||||
);
|
||||
expect(stack).toMatch(/direction="Column"/);
|
||||
expect(stack).toMatch(/gap="100"/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue