Compare commits

...

5 Commits

Author SHA1 Message Date
Maurice 322d5d5209 test(map): the phone hook mock keeps the number of hooks stable
The mock called the real useIsPhone only while a test left the answer open,
so a map still mounted when the answer was reset rendered with one hook more
and React threw. It now calls the real hook every time and overrides only its
answer.
2026-11-08 00:05:54 +01:00
Maurice 0346f9c13d refactor(mobile): the file preview's heading as its own part
The type tile beside the name took the preview pane past the complexity
limit; the heading (tile, name, type, wallet, size, date, uploader) is now a
part of its own and the pane reads as before.
2026-11-08 00:04:47 +01:00
Maurice dfd87d413e fix(atlas): measure the opening pan only where there is chrome to clear
The opening view asked Leaflet for the north edge's point on every atlas,
though only the touch shell's rail and header need a pan. The desktop atlas
now opens exactly as before, without the call, which its map mocks also
never had.
2026-11-08 00:02:20 +01:00
Maurice 27aab5c783 test(journey): wait for the dropped create parameter to reach the URL
The draft opens and the parameter goes in the same effect, but the URL shows
it a render later. Under CI load the check ran in between and saw
?create=entry; it now waits for the empty search like it waits for the draft.
2026-11-07 23:59:38 +01:00
Maurice a7cc1b9e57 fix(mobile): the checks the large-screen merge left red
- The trip's segmented control writes its fit through a ref, not into the
  element held in state (react-hooks/immutability).
- The amount field of a payment uses the body type tier, which follows the
  text size setting, instead of a raw size.
- The form sheet tests wait the frame the edge fade now measures on, and read
  the wide variant from the size class the shell writes.
- The native dashes spec lists its locales through node:fs, so shared needs no
  vite types for it (knip).
- Baselines lowered where the fixes removed warnings: no-misused-promises,
  no-non-null-assertion in tests, the MSheet rtl entry.
2026-11-07 23:56:33 +01:00
10 changed files with 102 additions and 51 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 847,
"@typescript-eslint/no-misused-promises": 845,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1912,
"@typescript-eslint/no-non-null-assertion": 1911,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
-1
View File
@@ -79,7 +79,6 @@
"hooks/useAnchoredPosition.ts": 3,
"hooks/useElementRect.ts": 3,
"mobile/components/MSegmented.tsx": 1,
"mobile/components/MSheet.tsx": 1,
"mobile/screens/journey/MJourneyDetail.tsx": 3,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
"mobile/screens/trip/map/MMapArea.tsx": 3,
+9 -1
View File
@@ -211,7 +211,15 @@ vi.mock('../../services/photoService', () => ({
const shell = vi.hoisted(() => ({ phone: undefined as boolean | undefined }))
vi.mock('../../mobile/useIsPhone', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../mobile/useIsPhone')>()
return { ...actual, useIsPhone: () => shell.phone ?? actual.useIsPhone() }
// The real hook runs every time, so a test that changes the answer never changes the
// number of hooks a mounted map has (React throws on that).
return {
...actual,
useIsPhone: () => {
const real = actual.useIsPhone()
return shell.phone ?? real
},
}
})
vi.mock('./gcj02Crs', () => ({
@@ -1,5 +1,5 @@
import { ChevronLeft, ChevronRight, FolderSync, List, Plus, Rows3 } from 'lucide-react';
import { useLayoutEffect, useState, type ReactNode } from 'react';
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { canManageDocSync } from '../../../components/Files/docsync/useDocSync';
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered';
import { useIsWide } from '../../../hooks/sizeClass';
@@ -26,14 +26,21 @@ import { collabBoardFor, collabSideBySide } from './tabs/collabPanes';
export function GlassSegment({ children, activeKey }: { children: ReactNode; activeKey?: string }) {
const [region, setRegion] = useState<HTMLDivElement | null>(null);
const [track, setTrack] = useState<HTMLDivElement | null>(null);
// The element is also kept in a ref: the fit below writes to it, and state is not to be written.
const trackRef = useRef<HTMLDivElement | null>(null);
const attachTrack = useCallback((el: HTMLDivElement | null) => {
trackRef.current = el;
setTrack(el);
}, []);
const fade = useEdgeFade(track);
useLayoutEffect(() => {
if (!region || !track) return;
const el = trackRef.current;
if (!region || !el) return;
const fit = () => {
track.dataset.tight = 'false';
track.dataset.tight = track.scrollWidth > track.clientWidth + 0.5 ? 'true' : 'false';
const active = track.querySelector<HTMLElement>('[aria-pressed="true"]');
if (active && track.scrollWidth > track.clientWidth + 0.5) {
el.dataset.tight = 'false';
el.dataset.tight = el.scrollWidth > el.clientWidth + 0.5 ? 'true' : 'false';
const active = el.querySelector<HTMLElement>('[aria-pressed="true"]');
if (active && el.scrollWidth > el.clientWidth + 0.5) {
// 'nearest' on both axes: the shell root is fixed, anything else scrolls the page behind it.
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
@@ -49,7 +56,7 @@ export function GlassSegment({ children, activeKey }: { children: ReactNode; act
className="pointer-events-none absolute inset-y-0 end-12 start-12 flex items-center justify-center"
>
<div className="pointer-events-auto flex min-w-0 max-w-full rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
<div ref={setTrack} className={`m-seg flex min-w-0 overflow-x-auto rounded-full ${edgeFadeClass(fade)}`}>
<div ref={attachTrack} className={`m-seg flex min-w-0 overflow-x-auto rounded-full ${edgeFadeClass(fade)}`}>
{children}
</div>
</div>
@@ -182,7 +182,7 @@ export function AddPaymentSheet({
onChange={(e) => setAmount(e.target.value)}
placeholder={zeroAmountInput(currency)}
aria-label={t('costs.amount')}
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-[0.84375rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-body font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:font-normal placeholder:text-m-faint"
/>
<span className="flex-none font-geist text-caption font-bold text-m-faint">{currency}</span>
</div>
@@ -123,7 +123,10 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
)}
</span>
<span className="flex flex-1 flex-col gap-[6px] px-3 pb-[10px] pt-2">
<span dir="auto" className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink">
<span
dir="auto"
className="line-clamp-2 break-words text-[length:calc(12.5px*var(--fs-scale-body,1))] font-bold leading-[1.3] text-m-ink"
>
{file.original_name}
</span>
<span className="mt-auto flex flex-wrap items-center gap-1">
@@ -166,7 +169,10 @@ export function FileTile({ file, selected, canStar, onPick, onStar, onMenu, t }:
function Badge({ children }: { children: ReactNode }) {
return (
// A size, a date or a name, each in its own direction ("46.1 KB" stays so in Arabic).
<span dir="auto" className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted">
<span
dir="auto"
className="whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-muted"
>
{children}
</span>
);
@@ -181,6 +187,43 @@ interface FilePreviewPaneProps {
onMenu: () => void;
}
/** The pane's heading: the type's tile when nothing is shown in place, the name, and what the file is. */
function FileHeading({ file, shownInPlace }: { file: TripFile; shownInPlace: boolean }) {
const { locale } = useTranslation();
const meta = getFileTypeMeta(file);
return (
<div className="flex items-start gap-3">
{!shownInPlace && (
<span className="relative block h-[92px] w-[74px] flex-none overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
<TypeArt file={file} />
</span>
)}
<div className={`min-w-0 flex-1 ${shownInPlace ? '' : 'pt-1'}`}>
<h3 className="break-words text-[length:calc(16px*var(--fs-scale-subtitle,1))] font-extrabold leading-[1.3] text-m-ink">
<bdi>{file.original_name}</bdi>
</h3>
<div className="mt-[6px] flex flex-wrap items-center gap-1">
<span
className="rounded-full px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-extrabold tracking-[.06em]"
style={{ background: `${meta.color}1f`, color: meta.color }}
>
{meta.label}
</span>
{isWalletPass(file.mime_type, file.original_name) && (
<span className="flex items-center gap-[3px] rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-actfg">
<Wallet size={9} strokeWidth={2.4} aria-hidden="true" />
{APPLE_WALLET}
</span>
)}
{!!file.file_size && <Badge>{formatSize(file.file_size)}</Badge>}
<Badge>{formatFileDate(file.created_at, locale)}</Badge>
{file.uploaded_by_name && <Badge>{file.uploaded_by_name}</Badge>}
</div>
</div>
</div>
);
}
/**
* The file picked in the grid, shown beside it once the window has room for
* two panes, the way Files on an iPad previews a document: the picture or the
@@ -191,8 +234,6 @@ interface FilePreviewPaneProps {
*/
export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu }: FilePreviewPaneProps) {
const { t } = planner;
const { locale } = useTranslation();
const meta = getFileTypeMeta(file);
const image = isImage(file.mime_type);
const pdf = isPdf(file) && pdfInPage();
const src = useSignedUrl(file.url, image || pdf);
@@ -229,35 +270,7 @@ export function FilePreviewPane({ file, planner, onOpen, onStar, onLinks, onMenu
</div>
)}
<div className="flex items-start gap-3">
{!shownInPlace && (
<span className="relative block h-[92px] w-[74px] flex-none overflow-hidden rounded-xl border border-[color:var(--m-rowbr)] bg-m-sheetop">
<TypeArt file={file} />
</span>
)}
<div className={`min-w-0 flex-1 ${shownInPlace ? '' : 'pt-1'}`}>
<h3 className="break-words text-[length:calc(16px*var(--fs-scale-subtitle,1))] font-extrabold leading-[1.3] text-m-ink">
<bdi>{file.original_name}</bdi>
</h3>
<div className="mt-[6px] flex flex-wrap items-center gap-1">
<span
className="rounded-full px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-extrabold tracking-[.06em]"
style={{ background: `${meta.color}1f`, color: meta.color }}
>
{meta.label}
</span>
{isWalletPass(file.mime_type, file.original_name) && (
<span className="flex items-center gap-[3px] rounded-full bg-m-act px-[7px] py-[2px] font-geist text-[length:calc(9px*var(--fs-scale-caption,1))] font-bold text-m-actfg">
<Wallet size={9} strokeWidth={2.4} aria-hidden="true" />
{APPLE_WALLET}
</span>
)}
{!!file.file_size && <Badge>{formatSize(file.file_size)}</Badge>}
<Badge>{formatFileDate(file.created_at, locale)}</Badge>
{file.uploaded_by_name && <Badge>{file.uploaded_by_name}</Badge>}
</div>
</div>
</div>
<FileHeading file={file} shownInPlace={shownInPlace} />
{(linked.places.length > 0 || linked.reservations.length > 0 || file.note_id != null) && (
<div className="flex flex-wrap gap-[6px]">
+3 -1
View File
@@ -374,7 +374,9 @@ export function useAtlas(options: { chromeInsets?: () => AtlasChromeInsets } = {
mapInstance.current = map
// The opening view centres the world beside the screen's chrome, not behind the rail and under the header.
const pan = chromePan(chromeInsetsRef.current?.(), map.latLngToContainerPoint([NORTH_EDGE_LAT, 0]).y)
// Measured only where there is chrome to clear: the desktop atlas opens exactly as before.
const insets = chromeInsetsRef.current?.()
const pan = insets ? chromePan(insets, map.latLngToContainerPoint([NORTH_EDGE_LAT, 0]).y) : null
if (pan) map.panBy(pan, { animate: false })
// Both renderers live as long as the map does (see the note on the refs); map.remove()
// disposes them along with every other layer it holds.
@@ -162,7 +162,8 @@ describe('useJourneyDetail', () => {
setup({ search: '?create=entry' });
await waitFor(() => expect(latest.editingEntry).not.toBeNull());
expect(latest.editingEntry).toMatchObject({ id: 0, journey_id: 7, type: 'entry', visibility: 'private' });
expect(screen.getByTestId('search')).toHaveTextContent('');
// The parameter goes in the same effect but reaches the URL a render later.
await waitFor(() => expect(screen.getByTestId('search')).toBeEmptyDOMElement());
});
it('FE-JRN-DETHOOK-004: a viewer never gets the draft editor', async () => {
+9 -2
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { useRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { sizeClassOf } from '../../../src/hooks/sizeClass';
import { useAutoGrow } from '../../../src/hooks/useAutoGrow';
import MSheet from '../../../src/mobile/components/MSheet';
import { edgeFadeClass, useEdgeFade } from '../../../src/mobile/components/useEdgeFade';
@@ -115,7 +116,8 @@ describe('useEdgeFade on the vertical axis', () => {
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 800 });
await act(async () => {
el.appendChild(document.createElement('div'));
await Promise.resolve();
// Measured on the next frame, so a burst of changes is measured once.
await new Promise((resolve) => setTimeout(resolve, 40));
});
expect(result.current.end).toBe(true);
});
@@ -162,7 +164,12 @@ describe('form sheet stylesheet', () => {
it('FE-MOB-FORMSHEET-010: the short-window variant is the other side of the wide size class height bound', () => {
expect(tailwind).toContain("addVariant('short', '@media (max-height: 500px)')");
expect(tailwind).toContain("addVariant('wide', '@media (min-width: 600px) and (min-height: 501px)')");
// `wide:` follows the size class the shell writes (data-size), whose height bound starts at 501.
expect(tailwind).toContain(
`addVariant('wide', ':where(html:has(.m-shell[data-size="wide"], .m-shell[data-size="split"])) &')`
);
expect(sizeClassOf(1024, 500)).toBe('compact');
expect(sizeClassOf(1024, 501)).not.toBe('compact');
});
it('FE-MOB-FORMSHEET-011: on a short window the actions sit beside the title and the fields take the rest', () => {
+16 -2
View File
@@ -1,6 +1,9 @@
/// <reference types="vite/client" />
/// <reference types="node" />
import type { TranslationStrings } from './types';
import { existsSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
@@ -11,7 +14,18 @@ import { describe, expect, it } from 'vitest';
* reads, and the style is held here because a re-translation can bring a dash
* back without any other check noticing.
*/
const nativeModules = import.meta.glob<{ default: TranslationStrings }>('./*/native.ts', { eager: true });
const here = dirname(fileURLToPath(import.meta.url));
const locales = readdirSync(here, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && existsSync(join(here, entry.name, 'native.ts')))
.map((entry) => entry.name);
const nativeModules: Record<string, { default: TranslationStrings }> = Object.fromEntries(
await Promise.all(
locales.map(async (locale) => [
`./${locale}/native.ts`,
(await import(`./${locale}/native.ts`)) as { default: TranslationStrings },
]),
),
);
const DASH = /[‒–—―]| - /;